91 Commits

Author SHA1 Message Date
Andras Bacsai 63a882107a Merge pull request #37 from coollabsio/add-deployment-logs-cli
Add deployment management commands for improved user experience
2025-11-10 14:21:49 +01:00
Andras Bacsai 08cd3b8ac7 fix: resolve nilerr linting error in deployment logs pretty-print logic
Restructure JSON pretty-print logic to check for success instead of
failure, avoiding nilerr linting violation while maintaining fallback
behavior. The change ensures proper error handling patterns without
hiding potential bugs.
2025-11-10 14:19:29 +01:00
Andras Bacsai 06f191e9ba Merge branch 'v4.x' into add-deployment-logs-cli 2025-11-10 14:18:09 +01:00
Andras Bacsai 7a19a02c02 fix: resolve remaining nilerr linting errors
- Explicitly ignore errors where we have intentional fallback behavior
- Use blank identifier (_) for errors we don't need to check
- Restructure error checking to avoid nilerr pattern
- All errors are properly handled or explicitly ignored
2025-11-10 13:55:10 +01:00
Andras Bacsai 806a6b9716 fix: resolve linting errors (errcheck, nilerr, revive, gci)
- Add error checking for w.Write calls in tests
- Rename unused http.Request parameters to _ in test handlers
- Fix nilerr errors by using different variable names to avoid shadowing
- Fix gci import grouping (stdlib, external, internal)
2025-11-10 13:48:58 +01:00
Andras Bacsai a18d751ad4 style: fix gofmt formatting in deployment_test.go 2025-11-10 13:44:42 +01:00
Andras Bacsai 9283717821 fix: add --format flag support and filter hidden logs in JSON output
Add proper support for --format flag (json/pretty/table) in deployment logs:
- Respect global --format flag for output formatting
- Filter hidden logs by default in JSON/pretty formats
- Only show debug logs when --debuglogs flag is present
- Apply --lines limit only to table format (JSON shows complete data)

Changes:
- Add GetLogsByDeploymentWithFormat() and GetLogsByApplicationWithFormat()
- Filter log entries with "hidden": true unless --debuglogs is set
- Return raw JSON for --format json
- Return pretty-printed JSON for --format pretty
- Return human-readable text for --format table (default)
2025-11-10 13:26:29 +01:00
Andras Bacsai a44c712163 feat: add deployment logs commands with human-readable output
Add new commands to query and display deployment logs:
- coolify app deployments list <app-uuid>: List all deployments for an application
- coolify app deployments logs <app-uuid> [deployment-uuid>]: Get deployment logs

Features:
- Retrieves latest deployment logs by default or specific deployment by UUID
- Parses JSON log format into human-readable line-by-line output
- Supports --lines flag to limit output to last N lines
- Supports --follow flag for real-time log streaming
- Supports --debuglogs flag to show hidden debug commands
- Uses API pagination (skip/take) for efficient deployment fetching
- Sorts deployments by timestamp to ensure latest is selected

Implementation:
- Add DeploymentsListResponse to handle paginated API responses
- Add log parsing utilities to format JSON logs as plain text
- Add comprehensive test coverage for deployment service methods
- Update README with new command documentation
2025-11-10 13:18:15 +01:00
Andras Bacsai 7c42af6203 Merge pull request #36 from coollabsio/fix/remove-file-check
Remove gzip file validation check
2025-11-10 12:34:29 +01:00
Andras Bacsai ed2dbd4947 Merge pull request #34 from YaRissi/version-inject
feat: Version inject
2025-11-10 12:33:57 +01:00
Laurence Jones f1301c1dbd Remove gzip file validation check
fix #35 

Removed basic check for gzip compressed file, since tar will error if not valid type anyways.
2025-11-07 08:08:31 +00:00
YaRissi 8a58cdd72f fix: update documentation to reflect new version retrieval location 2025-10-29 22:07:58 +01:00
Yassir Elmarissi 5ca4c3a8e3 Merge branch 'v4.x' into version-inject 2025-10-29 15:33:27 +01:00
Andras Bacsai 89cd744696 Merge pull request #33 from YaRissi/ci
feat: testing ci with linter
2025-10-29 15:22:39 +01:00
Andras Bacsai 694b4f8e32 Merge pull request #31 from YaRissi/go-install
feat: installable via go
2025-10-29 15:18:10 +01:00
Yassir Elmarissi ef15d013da fix: update regex for version injection
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-10-29 00:14:51 +01:00
YaRissi 9f5a44be04 fix: comment 2025-10-28 23:51:16 +01:00
YaRissi 1078a9d3ca fix: improve instance validation and error handling in context switching 2025-10-28 23:42:04 +01:00
YaRissi 9cef9ebee7 fix: improve error handling in commands 2025-10-28 23:27:03 +01:00
YaRissi 51f33cfc5e reactor: change context.Background zu cmd.Context 2025-10-28 23:15:02 +01:00
YaRissi 559d4e2709 chore: update commit message for version bump in release workflow 2025-10-21 01:02:43 +02:00
YaRissi 94e237e2b1 fix: specify branch reference in action and fix readme 2025-10-21 00:57:52 +02:00
YaRissi 3dd0dfdce1 fix: stage version file before committing 2025-10-21 00:34:10 +02:00
YaRissi aac44a0ddb fix: remove parallelism setting in goreleaser configuration 2025-10-21 00:28:22 +02:00
YaRissi a4c96d2803 fix: correct regex for version update in checker.go 2025-10-21 00:23:43 +02:00
YaRissi dc9670992a feat: implement version update automation 2025-10-21 00:13:55 +02:00
YaRissi c369689131 add coverage to test command 2025-10-18 13:50:45 +02:00
YaRissi dcf0b39c1b fix: golangci-lint version 2025-10-18 13:46:46 +02:00
YaRissi 14a3f00c57 refactor: update command handlers to use RunE for error handling 2025-10-18 13:37:31 +02:00
YaRissi 86f77716ee fix: all found problems by the linter 2025-10-18 13:13:35 +02:00
YaRissi ef91ed987e feat: testing ci with linter and pull request template 2025-10-18 13:12:21 +02:00
Yassir Elmarissi d8cf7a5986 docs: update installation section to emphasize the recommended install script 2025-10-17 21:32:44 +00:00
YaRissi d30b0b90de fix: run commands paths 2025-10-17 23:08:13 +02:00
YaRissi 4a8a659090 fix: update build commands and paths in configuration and documentation 2025-10-17 22:57:05 +02:00
Andras Bacsai aa1bda4063 Merge pull request #32 from coollabsio/andrasbacsai/env-sync-analysis
Add flags to env sync command
2025-10-17 22:29:43 +02:00
Andras Bacsai 69f2a7ac1f Changes auto-committed by Conductor 2025-10-17 22:29:14 +02:00
Yassir Elmarissi ad185a42ee update goreleaser 2025-10-17 14:58:05 +00:00
Yassir Elmarissi 22e34fb72e add dist to gitignore 2025-10-17 11:32:39 +00:00
Yassir Elmarissi c5b0ad4218 feat: installable via go and update gorealeser 2025-10-17 11:29:04 +00:00
Andras Bacsai 1a6fa9e397 Merge pull request #29 from coollabsio/andrasbacsai/parallel-goreleaser-builds
Enable parallel GoReleaser builds
2025-10-17 11:41:30 +02:00
Andras Bacsai b767468b29 Changes auto-committed by Conductor 2025-10-17 11:40:59 +02:00
Andras Bacsai 6b742d37dd Update checker.go 2025-10-17 11:35:47 +02:00
Andras Bacsai 0b2d277e41 Merge pull request #27 from coollabsio/contributing-guide
Add CONTRIBUTING.md file
2025-10-17 11:35:23 +02:00
Andras Bacsai ac9a486d46 Merge pull request #28 from coollabsio/andrasbacsai/add-min-version-reqs
Add minimum version checks to CLI commands
2025-10-17 11:35:15 +02:00
Andras Bacsai ba8f05769f Changes auto-committed by Conductor 2025-10-17 11:34:49 +02:00
Andras Bacsai 5c799410e5 Changes auto-committed by Conductor 2025-10-17 11:33:48 +02:00
Andras Bacsai d5dd1b5bdf Changes auto-committed by Conductor 2025-10-17 11:32:53 +02:00
Andras Bacsai 0d6a2bb1e9 Changes auto-committed by Conductor 2025-10-17 11:32:53 +02:00
Andras Bacsai 9eba5b97f7 Merge pull request #26 from coollabsio/andrasbacsai/check-readme
Update README with CLI command fixes
2025-10-17 11:26:10 +02:00
Andras Bacsai 76c1434711 Changes auto-committed by Conductor 2025-10-17 11:24:00 +02:00
Andras Bacsai f35d299f6e Update README.md 2025-10-17 11:17:17 +02:00
Andras Bacsai 680264ab3f Update README.md 2025-10-17 11:15:38 +02:00
Andras Bacsai 794025aee1 Changes auto-committed by Conductor (#25) 2025-10-17 11:14:24 +02:00
Andras Bacsai 8d6da93aa1 Merge pull request #24 from coollabsio/andrasbacsai/show-config-path
Add coolify config command
2025-10-17 11:05:57 +02:00
Andras Bacsai 22516fe51e Changes auto-committed by Conductor 2025-10-17 11:00:09 +02:00
Andras Bacsai fe63c3a3b6 Changes auto-committed by Conductor 2025-10-17 10:59:12 +02:00
Andras Bacsai 6cad3ba0f7 Merge pull request #23 from coollabsio/cli-ux-improvements
Refactor: Use 'context' instead of 'instance' terminology
2025-10-17 10:46:51 +02:00
Andras Bacsai 1bc4625ef8 fix: properly hide sensitive data in all commands
- Add sensitive:"true" tag to Instance.Token field
- Add sensitive:"true" tag to GitHub ClientSecret and WebhookSecret
- Add sensitive:"true" tag to EnvironmentVariable Value and RealValue
- Tokens now hidden by default, shown with --show-sensitive flag

This ensures sensitive data like API tokens, secrets, and environment
variable values are masked (********) by default in table output and
only shown when the user explicitly uses the --show-sensitive flag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 09:27:13 +02:00
Andras Bacsai d9494301f6 fix: add trailing newline to table output
Fixes the zsh prompt indicator (%) appearing after table output
by ensuring all table output ends with a newline character.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 09:21:48 +02:00
Andras Bacsai 85cf8f6981 Merge branch 'v4.x' into cli-ux-improvements
Resolved conflicts in README.md and database commands.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 09:19:11 +02:00
Andras Bacsai c98a8330f8 Changes auto-committed by Conductor 2025-10-17 09:13:39 +02:00
Andras Bacsai 49e5870c86 Merge pull request #22 from YaRissi/fix/missing-flags
Fix: missing flags for some commands
2025-10-17 09:13:16 +02:00
YaRissi 705fed3560 fix: update README to reflect context management commands and terminology 2025-10-17 01:21:52 +02:00
YaRissi 88d0b25537 fix: correct flag names for local backup retention in create command and update README 2025-10-17 01:10:28 +02:00
YaRissi 3470b04235 fix: add missing flags to commands 2025-10-17 00:54:05 +02:00
Andras Bacsai eb876e4bda Merge pull request #21 from YaRissi/folder-file-structure
Proposal: Restructure of commands
2025-10-17 00:19:02 +02:00
Andras Bacsai 7210b8df76 refactor: Rename instances to contexts and add update command 2025-10-17 00:09:48 +02:00
YaRissi 0546fd1932 fix: autocompletion docs 2025-10-16 23:22:28 +02:00
YaRissi e2c1c86194 fix: team models 2025-10-16 23:08:16 +02:00
YaRissi 84e7e4921b fix: move command to get server domains by UUID and remove non existing domain endpoint 2025-10-16 22:55:53 +02:00
YaRissi 0acb1fc512 fix: docs command 2025-10-16 22:34:20 +02:00
YaRissi eaf9614bcc Refactor CLI commands files
- Introduced new structured command files for teams: current, get, list, and members.
- Created a new CLI client helper for API interactions.
- Removed deprecated version command and replaced it with a new structure.
- Moved utility functions from root to cli folder in internal
2025-10-16 22:29:02 +02:00
Andras Bacsai 8ec750ecc6 Changes auto-committed by Conductor (#19) 2025-10-16 17:02:52 +02:00
Andras Bacsai 76396c3c06 Merge pull request #18 from coollabsio/andrasbacsai/review-install-script
Enhance install script with features and error handling
2025-10-16 14:56:58 +02:00
Andras Bacsai 3286229a06 Changes auto-committed by Conductor 2025-10-16 14:42:15 +02:00
Andras Bacsai 884b687947 Merge pull request #17 from coollabsio/andrasbacsai/api-endpoint-expansion
update README.md
2025-10-16 14:09:57 +02:00
Andras Bacsai 11f9baafc6 feat: expand CLI documentation with application, database, and service management commands 2025-10-16 14:09:20 +02:00
Andras Bacsai f4f628fdae Merge pull request #16 from coollabsio/andrasbacsai/api-endpoint-expansion
Refactor cli and use all available endpoints
2025-10-16 14:01:53 +02:00
Andras Bacsai ae086bbbbd Changes auto-committed by Conductor 2025-10-16 13:49:17 +02:00
Andras Bacsai 401f4bf317 Changes auto-committed by Conductor 2025-10-16 13:48:57 +02:00
Andras Bacsai 095b5a5bc5 Merge pull request #14 from coollabsio/add-testing-framework
docs: update CLAUDE.md with correct test commands
2025-10-14 23:26:08 +02:00
Andras Bacsai 002e206c54 feat: add air file watcher for hot reload during development
- Add .air.toml configuration for automatic rebuilds
- Update conductor.json run script to use air
- Update conductor-setup.sh to install air automatically
- Air watches .go files and rebuilds coolify binary on changes
- Excludes test files, vendor, and conductor directories from watch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 23:24:20 +02:00
Andras Bacsai c865aa7512 docs: update CLAUDE.md with correct test commands
- Update test commands to use ./internal/... path
- Add note about httptest.NewServer() for API mocking
- Clarify that tests never call real external APIs
- Update checklist to reflect internal/ test structure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 23:16:22 +02:00
Andras Bacsai 3788d6b812 Merge pull request #13 from coollabsio/create-script
feat: add Conductor workspace configuration
2025-10-14 23:01:31 +02:00
Andras Bacsai 7d23eac444 feat: add Conductor workspace configuration
Add conductor.json and setup script to enable automated workspace setup:
- Auto-installs Go dependencies
- Validates Go version (1.24+)
- Builds coolify binary on workspace creation
- Configures test runner for development

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 23:00:59 +02:00
Andras Bacsai 6aa77a4840 Merge pull request #12 from coollabsio/update-go-deps
chore: update Go dependencies to fix security vulnerabilities
2025-10-14 22:55:39 +02:00
Andras Bacsai 17f1435ce1 chore: update Go dependencies to fix security vulnerabilities
Updated key dependencies to address security vulnerabilities:
- golang.org/x/oauth2: v0.25.0 → v0.32.0 (High severity)
- golang.org/x/crypto: v0.32.0 → v0.43.0 (High severity DoS)
- github.com/ulikunitz/xz: v0.5.12 → v0.5.15 (Moderate memory leak)

Also updated:
- Go version: 1.22.0 → 1.24.6
- github.com/spf13/cobra: v1.8.1 → v1.10.1
- github.com/spf13/viper: v1.19.0 → v1.21.0
- 20+ other dependency updates

All tests passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 22:54:47 +02:00
Andras Bacsai 485b4c5f6b Merge pull request #9 from jizaymes/fix-update-text
fix cmd typo prompting user to update coolify
2025-10-14 22:50:15 +02:00
Andras Bacsai d930e6cf9f Merge pull request #11 from coollabsio/andrasbacsai/cli-restructure
feat: Complete CLI restructure with layered architecture
2025-10-14 22:49:15 +02:00
Andras Bacsai 519fe69ebf feat: complete CLI restructure with layered architecture
This commit completes a comprehensive restructuring of the Coolify CLI, implementing a clean layered architecture with improved maintainability, testability, and user experience.

## Major Changes

### Architecture Refactoring (Phases 1-3)
- Separated concerns into distinct layers: Commands → Services → API Client
- Created `internal/api/` - HTTP client with retry logic, error handling
- Created `internal/service/` - Business logic layer for all operations
- Created `internal/config/` - Multi-instance configuration management
- Created `internal/models/` - Shared data structures
- Created `internal/output/` - Pluggable formatters (table/json/pretty)

### New Features (Phase 4)
- **Named deployments**: `coolify deploy name <name>` - Deploy resources by name
- **Batch deployments**: `coolify deploy batch <name1,name2,...>` - Deploy multiple resources
- **Instance selection**: `--instance <name>` flag to use specific instances
- **Improved error messages**: Clean, actionable error output without usage clutter

### Testing & Documentation (Phase 5)
- **Test coverage**: 80%+ across all internal packages
- **Man pages**: Professional documentation (39 man pages)
- **Architecture docs**: Comprehensive ARCHITECTURE.md with diagrams
- **Practical examples**: Real-world scripts for CI/CD, multi-env deployments
- **Complete Godoc**: All public functions documented

## Benefits

### For Users
- More intuitive commands with consistent patterns
- Better error messages and debugging
- Multi-instance support for prod/staging workflows
- Professional documentation (man pages, examples)

### For Developers
- Clear separation of concerns
- Comprehensive test suite (80%+ coverage)
- Easy to add new commands/features
- Well-documented architecture

## Commands Enhanced

- `servers` - Refactored to use service layer
- `projects` - Improved with service pattern
- `deploy` - Added name-based and batch deployments
- `resources` - Refactored with cleaner code
- `domains` - Updated to new architecture
- `private-keys` - Improved with service layer
- `instances` - Enhanced configuration management

## New Commands

- `coolify deploy name <name>` - Deploy by resource name
- `coolify deploy batch <names>` - Deploy multiple resources
- `coolify docs man` - Generate man pages
- `coolify docs markdown` - Generate markdown documentation

## Files Added

### Core Architecture
- `internal/api/` - API client layer (4 files)
- `internal/service/` - Service layer (12 files)
- `internal/config/` - Configuration (4 files)
- `internal/models/` - Data models (8 files)
- `internal/output/` - Output formatters (5 files)

### Documentation
- `ARCHITECTURE.md` - Comprehensive architecture guide
- `CLAUDE.md` - Development instructions
- `examples/` - Practical usage examples

### Testing
- `test/fixtures/` - Test data
- `*_test.go` - Comprehensive test suites (80%+ coverage)

## Breaking Changes

None - All existing commands remain backward compatible.

## Migration Guide

No migration needed. All existing workflows continue to work.

New features are opt-in:
- Use `--instance` flag for multi-instance setups
- Use `deploy name` for name-based deployments
- Use `deploy batch` for multiple deployments

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 22:29:11 +02:00
James Cornman 4e0575a156 update text prompting user to update coolify 2025-05-20 11:00:09 -05:00
220 changed files with 17609 additions and 20715 deletions
+46
View File
@@ -0,0 +1,46 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./coolify"
cmd = "go build -o ./coolify ./coolify"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", ".git", ".conductor"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = "echo 'Build complete. Binary: ./coolify/coolify'"
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = true
keep_scroll = true
+25 -38
View File
@@ -1,42 +1,29 @@
You are an expert AI programming assistant specializing in building CLI applications with Go, using Cobra for command-line interface management and Bubble Tea for terminal user interfaces.
Always use Go 1.24 and be familiar with CLI development best practices, Go idioms, and terminal UI design principles.
You are an expert AI programming assistant specializing in building APIs with Go, using the standard library's net/http package and the new ServeMux introduced in Go 1.22.
When using lipgloss for terminal styling, use these Coolify brand colors via the pkg/tui package.
Always use the latest stable version of Go (1.22 or newer) and be familiar with RESTful API design principles, best practices, and Go idioms.
When searching for schemas look at https://github.com/coollabsio/coolify/blob/main/openapi.yaml to find the most up to date schema for the struct we are looking to define. Make sure when creating a schema that you place the struct in cmd/coolTypes package.
- Follow the user's requirements carefully & to the letter.
- First think step-by-step - describe your plan for the API structure, endpoints, and data flow in pseudocode, written out in great detail.
- Confirm the plan, then write code!
- Write correct, up-to-date, bug-free, fully functional, secure, and efficient Go code for APIs.
- Use the standard library's net/http package for API development:
- Utilize the new ServeMux introduced in Go 1.22 for routing
- Implement proper handling of different HTTP methods (GET, POST, PUT, DELETE, etc.)
- Use method handlers with appropriate signatures (e.g., func(w http.ResponseWriter, r *http.Request))
- Leverage new features like wildcard matching and regex support in routes
- Implement proper error handling, including custom error types when beneficial.
- Use appropriate status codes and format JSON responses correctly.
- Implement input validation for API endpoints.
- Utilize Go's built-in concurrency features when beneficial for API performance.
- Follow RESTful API design principles and best practices.
- Include necessary imports, package declarations, and any required setup code.
- Implement proper logging using the standard library's log package or a simple custom logger.
- Consider implementing middleware for cross-cutting concerns (e.g., logging, authentication).
- Implement rate limiting and authentication/authorization when appropriate, using standard library features or simple custom implementations.
- Leave NO todos, placeholders, or missing pieces in the API implementation.
- Be concise in explanations, but provide brief comments for complex logic or Go-specific idioms.
- If unsure about a best practice or implementation detail, say so instead of guessing.
- Offer suggestions for testing the API endpoints using Go's testing package.
- First think step-by-step - describe your plan for the CLI structure, commands, and user interaction flow in pseudocode, written out in great detail.
- Confirm the plan, then write code!
- Write correct, up-to-date, bug-free, fully functional, secure, and efficient Go code for CLI applications.
- Use Cobra for command-line interface development:
- Organize commands in a clear, hierarchical structure
- Implement proper command flags and arguments
- Use persistent flags when appropriate
- Follow Cobra's best practices for command organization
- Implement proper command aliases and short descriptions
- Use Bubble Tea for terminal user interfaces:
- Design intuitive and responsive terminal UIs
- Implement proper state management
- Handle user input appropriately
- Use appropriate Bubble Tea components and styling
- Follow terminal UI best practices
- Implement proper error handling, including custom error types when beneficial
- Use appropriate exit codes and error messages
- Implement input validation for command arguments and flags
- Utilize Go's built-in concurrency features when beneficial for CLI performance
- Follow CLI design principles and best practices:
- Keep commands simple and focused
- Use clear, consistent naming conventions
- Provide helpful usage information
- Implement proper help text and documentation
- Include necessary imports, package declarations, and any required setup code
- Implement proper logging using appropriate CLI-friendly logging packages
- Consider implementing middleware for cross-cutting concerns (e.g., logging, configuration)
- Implement proper configuration management when appropriate
- Leave NO todos, placeholders, or missing pieces in the CLI implementation
- Be concise in explanations, but provide brief comments for complex logic or Go-specific idioms
- If unsure about a best practice or implementation detail, say so instead of guessing
- Offer suggestions for testing the CLI commands using Go's testing package
Always prioritize user experience, maintainability, and cross-platform compatibility in your CLI designs and implementations. Leverage the power of Cobra and Bubble Tea to create efficient and user-friendly terminal applications.
Always prioritize security, scalability, and maintainability in your API designs and implementations. Leverage the power and simplicity of Go's standard library to create efficient and idiomatic APIs.
+7
View File
@@ -0,0 +1,7 @@
## Changes
-
## Issues & Discussions
- fix #
+29 -5
View File
@@ -10,18 +10,42 @@ jobs:
release-cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: ${{ env.GITHUB_REF_NAME }}
args: release --clean
workdir: ./
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-version:
needs: [release-cli]
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: v4.x
fetch-depth: 0
- name: Update version file
run: |
TAG=${GITHUB_REF#refs/tags/}
echo "Updating version to $TAG"
sed -i "s/^\tversion = \".*\"/\tversion = \"$TAG\"/" internal/version/checker.go
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add internal/version/checker.go
git commit -m "chore: bump version to $TAG"
git push origin v4.x
+55
View File
@@ -0,0 +1,55 @@
name: Testing CLI
on:
push:
branches: ["v4.x"]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Run gofmt
run: diff -u <(echo -n) <(gofmt -d -s .)
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.5.0 # pin version for consistency
test:
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: Run tests
run: go test -v -race -cover ./...
go-mod-tidy:
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: Run go mod tidy
run: go mod tidy
- name: Check uncommitted changes
run: git diff --exit-code
- if: failure()
run: echo "::error::Check failed, please run 'go mod tidy' and commit the changes."
+9 -6
View File
@@ -1,9 +1,12 @@
coolify-cli
cli-coolify
coolify
cli
/coolify
config.json
dist
.vagrant
.test
.claude
# Generated documentation (can be regenerated)
man/
docs/cli/
dist/
# Test coverage
coverage.out
+67 -14
View File
@@ -1,22 +1,75 @@
version: "2"
run:
timeout: 5m
linters:
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- contextcheck
- durationcheck
- errchkjson
- errorlint
- exhaustive
- gocheckcompilerdirectives
- gochecksumtype
- gocritic
settings:
gocritic:
enabled-tags:
- diagnostic
- style
- performance
disabled-checks:
- hugeParam
- rangeValCopy
- gomoddirectives
- gomodguard
- gosec
- gosmopolitan
- loggercheck
- makezero
- musttag
- nilerr
- nilnesserr
- noctx
- protogetter
- reassign
- recvcheck
- revive
- rowserrcheck
- spancheck
- sqlclosecheck
- testifylint
- unparam
- zerologlint
issues:
max-issues-per-linter: 0
max-same-issues: 0
settings:
exhaustive:
default-signifies-exhaustive: true
staticcheck:
checks: ["all", "-ST1005", "-S1016"]
gosec:
excludes:
- G115
gosmopolitan:
allow-time-local: true
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- std-error-handling
formatters:
enable:
- gci
- goimports
settings:
gci:
sections:
- standard
- default
- prefix(github.com/coollabsio)
exclusions:
paths:
- "pkg/gen/*.go"
generated: lax
+13 -14
View File
@@ -1,8 +1,19 @@
version: 2
before:
hooks:
- go mod tidy
builds:
- binary: coolify
- id: coolify
binary: coolify
flags:
- -trimpath
ldflags:
- -s
- -w
- -X github.com/coollabsio/coolify-cli/internal/version.version={{ .Version }}
main: ./coolify/main.go
goos:
- darwin
- linux
@@ -10,17 +21,5 @@ builds:
goarch:
- amd64
- arm64
ldflags:
- -s -w -X github.com/coollabsio/cli-coolify/cmd/runtime.Version={{.Version}}
env:
- CGO_ENABLED=0
archives:
- formats: ['tar.gz']
name_template: >-
coolify_{{ .Version }}_
{{- .Os }}_{{ .Arch }}
checksum:
name_template: 'coolify_{{ .Version }}_checksums.txt'
release:
prerelease: auto
make_latest: "{{ not .Prerelease }}"
- CGO_ENABLED=0
+587
View File
@@ -0,0 +1,587 @@
# Coolify CLI Architecture
This document describes the architecture and design principles of the Coolify CLI.
## Overview
The Coolify CLI is a command-line interface for managing Coolify instances, servers, projects, and deployments. It follows a layered architecture pattern that separates concerns and promotes maintainability.
## Architecture Layers
```
┌─────────────────────────────────────────────────────────┐
│ User Interface │
│ (Terminal/Shell) │
└────────────────────────┬────────────────────────────────┘
┌────────────────────────▼────────────────────────────────┐
│ Command Layer (cmd/) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ servers │ │ deploy │ │ projects │ ... │
│ └──────────┘ └──────────┘ └──────────┘ │
│ • CLI parsing & validation │
│ • Flag handling │
│ • Output formatting │
└────────────────────────┬────────────────────────────────┘
┌────────────────────────▼────────────────────────────────┐
│ Service Layer (internal/service/) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ServerService│ │DeployService │ │ProjectService│ │
│ └─────────────┘ └──────────────┘ └──────────────┘ │
│ • Business logic │
│ • Request validation │
│ • Response transformation │
└────────────────────────┬────────────────────────────────┘
┌────────────────────────▼────────────────────────────────┐
│ API Client Layer (internal/api/) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ HTTP Client (api.Client) │ │
│ └───────────────────────────────────────────────────┘ │
│ • HTTP requests/responses │
│ • Authentication (Bearer tokens) │
│ • Retry logic │
│ • Error handling │
└────────────────────────┬────────────────────────────────┘
┌────────────────────────▼────────────────────────────────┐
│ Coolify API (External) │
│ https://instance.coolify.io/api/v1/ │
└─────────────────────────────────────────────────────────┘
```
## Supporting Components
```
┌─────────────────────────────────────────────────────────┐
│ Configuration (internal/config/) │
│ • Multi-instance management │
│ • Default instance selection │
│ • Token storage │
│ • ~/.config/coolify/config.json │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Output Formatters (internal/output/) │
│ ┌─────────┐ ┌────────┐ ┌─────────┐ │
│ │ Table │ │ JSON │ │ Pretty │ │
│ └─────────┘ └────────┘ └─────────┘ │
│ • Flexible output formats │
│ • Sensitive data masking │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Data Models (internal/models/) │
│ • Server, Project, Resource, Deployment │
│ • Request/Response structures │
│ • JSON marshaling/unmarshaling │
└─────────────────────────────────────────────────────────┘
```
## Layer Responsibilities
### 1. Command Layer (`cmd/`)
**Purpose**: Handle CLI user interface and interaction
**Responsibilities**:
- Parse command-line arguments and flags
- Validate user input
- Coordinate with service layer
- Format and display output
- Handle errors gracefully
**Key Files**:
- `root.go` - Root command, global flags, initialization
- `servers.go` - Server management commands
- `deploy.go` - Deployment commands
- `context.go` - Context (instance) configuration commands
- `projects.go` - Project listing and inspection
- etc.
**Example**:
```go
var serversListCmd = &cobra.Command{
Use: "list",
Short: "List all servers",
RunE: func(cmd *cobra.Command, args []string) error {
// Get API client
client, err := getAPIClient(cmd)
if err != nil {
return err
}
// Use service layer
service := service.NewServerService(client)
servers, err := service.List(cmd.Context())
if err != nil {
return err
}
// Format and display output
formatter, _ := getFormatter(cmd)
return formatter.Format(servers)
},
}
```
### 2. Service Layer (`internal/service/`)
**Purpose**: Implement business logic and coordinate API calls
**Responsibilities**:
- Validate business rules
- Coordinate multiple API calls if needed
- Transform API responses to CLI-friendly format
- Handle service-specific error cases
**Key Files**:
- `server.go` - Server operations
- `deployment.go` - Deployment operations
- `project.go` - Project operations
- `resource.go` - Resource operations
- `privatekey.go` - SSH key operations
- `domain.go` - Domain operations
**Example**:
```go
type ServerService struct {
client *api.Client
}
func (s *ServerService) List(ctx context.Context) ([]models.Server, error) {
var servers []models.Server
err := s.client.Get(ctx, "servers", &servers)
return servers, err
}
```
### 3. API Client Layer (`internal/api/`)
**Purpose**: Handle all HTTP communication with Coolify API
**Responsibilities**:
- Construct HTTP requests
- Add authentication headers
- Retry failed requests with exponential backoff
- Parse HTTP responses
- Convert HTTP errors to meaningful error messages
**Key Files**:
- `client.go` - HTTP client implementation
- `error.go` - API error handling
- `options.go` - Client configuration options
**Example**:
```go
type Client struct {
baseURL string
token string
httpClient *http.Client
retries int
timeout time.Duration
}
func (c *Client) Get(ctx context.Context, path string, result interface{}) error {
return c.doRequest(ctx, "GET", path, nil, result)
}
```
### 4. Configuration Layer (`internal/config/`)
**Purpose**: Manage CLI configuration and multiple instances
**Responsibilities**:
- Load/save configuration from disk
- Manage multiple Coolify instances
- Select default instance
- Store API tokens securely (file permissions)
**Key Files**:
- `config.go` - Configuration structure and methods
- `instance.go` - Instance definition
- `loader.go` - File I/O operations
**Configuration File** (`~/.config/coolify/config.json`):
```json
{
"instances": [
{
"name": "prod",
"fqdn": "https://coolify.example.com",
"token": "your-api-token",
"default": true
},
{
"name": "staging",
"fqdn": "https://staging.coolify.example.com",
"token": "staging-token"
}
]
}
```
### 5. Output Layer (`internal/output/`)
**Purpose**: Format data for display to users
**Responsibilities**:
- Format data as tables, JSON, or pretty-printed JSON
- Hide sensitive information unless `--show-sensitive` is used
- Handle different data types (slices, structs, primitives)
**Key Files**:
- `formatter.go` - Formatter interface
- `table.go` - Table formatting
- `json.go` - JSON formatting
- `pretty.go` - Pretty JSON formatting
**Supported Formats**:
- `table` - Default, human-readable tables
- `json` - Compact JSON for scripting
- `pretty` - Indented JSON for debugging
### 6. Models Layer (`internal/models/`)
**Purpose**: Define data structures
**Responsibilities**:
- Define API request/response structures
- JSON tags for marshaling
- Common types and timestamps
**Key Files**:
- `server.go` - Server-related types
- `project.go` - Project-related types
- `resource.go` - Resource types
- `deployment.go` - Deployment types
- `common.go` - Shared types
## Data Flow
### Example: Listing Servers
1. **User Input**: `coolify servers list --format=table`
2. **Command Layer** (`cmd/servers.go`):
- Cobra parses the command
- `serversListCmd.RunE` is executed
- Gets API client using `getAPIClient()`
- Creates ServerService instance
3. **Service Layer** (`internal/service/server.go`):
- `ServerService.List()` is called
- Validates context (if needed)
- Calls API client
4. **API Client Layer** (`internal/api/client.go`):
- Constructs GET request to `/api/v1/servers`
- Adds Bearer token authentication
- Sends HTTP request
- Retries on failure (with backoff)
- Parses JSON response
5. **Response Processing**:
- JSON unmarshaled to `[]models.Server`
- Returns to service layer
- Returns to command layer
6. **Output Layer** (`internal/output/table.go`):
- Command layer creates table formatter
- Formatter processes server data
- Formats as table with columns
- Writes to stdout
7. **User Output**: Table displayed in terminal
## Design Patterns
### 1. Dependency Injection
Services receive the API client as a constructor parameter:
```go
func NewServerService(client *api.Client) *ServerService {
return &ServerService{client: client}
}
```
**Benefits**:
- Easy to test (can inject mock client)
- Clear dependencies
- Flexible configuration
### 2. Strategy Pattern (Output Formatters)
Different formatters implement the same interface:
```go
type Formatter interface {
Format(data interface{}) error
}
```
**Benefits**:
- Easy to add new formats
- Consistent API
- Runtime format selection
### 3. Options Pattern (API Client)
Client configuration uses functional options:
```go
client := api.NewClient(url, token,
api.WithDebug(true),
api.WithRetries(5),
api.WithTimeout(60 * time.Second),
)
```
**Benefits**:
- Optional parameters
- Clear intent
- Backward compatible
### 4. Error Wrapping
Errors are wrapped with context at each layer:
```go
if err != nil {
return fmt.Errorf("failed to list servers: %w", err)
}
```
**Benefits**:
- Error context preserved
- Stack trace maintained
- Better debugging
## Testing Strategy
### Unit Tests
Each layer has comprehensive unit tests:
- **Commands**: Mock services, test flag parsing
- **Services**: Mock API client, test business logic
- **API Client**: Use `httptest.Server`, test HTTP handling
- **Config**: Test file I/O with temp directories
- **Output**: Test formatting with buffers
### Integration Tests
Test multiple layers together:
- Commands + Services + Mock API
- Config + File System
- End-to-end workflows
### Coverage Goals
- Overall: 70%+
- New features: 80%+
- Critical paths: 90%+
## Configuration Files
### CLI Configuration
**Location**: `~/.config/coolify/config.json` (Linux/macOS)
**Location**: `%APPDATA%\coolify\config.json` (Windows)
**Structure**:
```json
{
"instances": [
{
"name": "prod",
"fqdn": "https://coolify.example.com",
"token": "your-token",
"default": true
}
],
"lastUpdateCheckTime": "2025-01-15T10:30:00Z"
}
```
## API Communication
### Base URL
All API calls use: `{fqdn}/api/v1/{endpoint}`
Example: `https://coolify.example.com/api/v1/servers`
### Authentication
Bearer token authentication:
```
Authorization: Bearer {token}
```
### Request/Response
**Content-Type**: `application/json`
**Request Body** (POST):
```json
{
"name": "my-server",
"ip": "192.168.1.100"
}
```
**Response Body**:
```json
{
"uuid": "abc123",
"name": "my-server",
"ip": "192.168.1.100"
}
```
### Error Handling
HTTP errors are converted to CLI-friendly messages:
- `401` → "Unauthenticated. Check your API token."
- `404` → "Resource not found."
- `500` → "Server error. Please try again."
### Retry Logic
Failed requests are retried with exponential backoff:
- Attempt 1: Immediate
- Attempt 2: Wait 1s
- Attempt 3: Wait 2s
- Attempt 4: Wait 4s
Does not retry on 4xx errors (except 429 rate limit).
## Security Considerations
### API Token Storage
- Stored in config file with restricted permissions (0600)
- Never logged (even in debug mode)
- Masked in output by default (use `-s` to show)
### Sensitive Data Handling
- Tokens masked as `********` in output
- Use `--show-sensitive` flag to reveal
- Debug logs sanitize sensitive data
### HTTPS
- All API communication uses HTTPS
- Certificate validation enabled
## Performance Optimizations
### Concurrent Operations
Batch deployments run in parallel:
```go
// Deploy multiple resources concurrently
var wg sync.WaitGroup
for _, name := range names {
wg.Add(1)
go func(n string) {
defer wg.Done()
deployResource(n)
}(name)
}
wg.Wait()
```
### Connection Reuse
HTTP client reuses connections:
```go
c.httpClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
},
}
```
### Minimal Dependencies
- Use Go standard library when possible
- Only essential external dependencies
- Keep binary size small
## Extensibility
### Adding a New Command
1. Create `cmd/newfeature.go`
2. Define Cobra command
3. Create service if needed (`internal/service/newfeature.go`)
4. Add models if needed (`internal/models/newfeature.go`)
5. Register command in `init()`
6. Write tests
### Adding a New Output Format
1. Create `internal/output/newformat.go`
2. Implement `Formatter` interface
3. Add format constant
4. Update `NewFormatter()` switch
### Adding API Client Features
1. Add method to `internal/api/client.go`
2. Add tests in `internal/api/client_test.go`
3. Use in service layer
## Build & Release
### Build Process
```bash
# Local build
go build -o coolify ./coolify
# Install locally
go install ./coolify
# Multi-platform release
goreleaser release --clean
```
### Release Artifacts
- Linux: amd64, arm64
- macOS: amd64, arm64 (Apple Silicon)
- Windows: amd64
### Distribution
- GitHub Releases
- Install script: `scripts/install.sh`
- Package managers (planned)
## Future Enhancements
- [ ] Shell completion improvements
- [ ] Interactive mode
- [ ] Configuration wizard
- [ ] Plugin system
- [ ] Telemetry (opt-in)
- [ ] Cache layer for frequent queries
## References
- [Cobra Documentation](https://cobra.dev/)
- [Coolify API Specification](https://github.com/coollabsio/coolify/blob/v4.x/openapi.json)
- [Go Project Layout](https://github.com/golang-standards/project-layout)
+343
View File
@@ -0,0 +1,343 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a CLI tool for interacting with the Coolify API, built with Go using the Cobra framework. The CLI allows users to manage Coolify instances (both cloud and self-hosted), servers, projects, resources, deployments, domains, and private keys.
### API Specification
This CLI is a client for the Coolify API. The API specification is defined in the OpenAPI schema:
- **Source**: https://github.com/coollabsio/coolify/blob/v4.x/openapi.json
- **Base Path**: `/api/v1/`
- **Authentication**: Bearer token (API tokens from Coolify dashboard at `/security/api-tokens`)
All commands in this CLI are wrappers around API endpoints defined in the OpenAPI specification. When adding new features or endpoints:
1. Check the OpenAPI spec for available endpoints and their request/response schemas
2. Ensure the CLI command structure follows the API resource hierarchy
3. Match the API's data types and validation rules
## Architecture
### Command Structure
The codebase follows Cobra's command pattern with a root command and subcommands:
- Entry point: `coolify/main.go` calls `cmd.Execute()`
- Root command: `cmd/root.go` - contains core utilities (HTTP client, authentication, version checking, config management)
- Subcommands: Each command is in its own file in `cmd/`:
- `context.go` - manage Coolify context (add, remove, list, set default/token)
- `servers.go` - list and get server information
- `projects.go` - list projects with environments and applications
- `resources.go` - list resources
- `deploy.go` - deploy resources
- `domains.go` - manage domains
- `privatekeys.go` - manage SSH keys
- `update.go` - self-update CLI
- `version.go` - show CLI version
### Configuration Management
- Uses Viper for configuration management
- Config file location: `~/.config/coolify/config.json` (via xdg package)
- Config stores multiple instances with tokens, default instance selection
- Global flags available: `--token`, `--host`, `--format`, `--show-sensitive`, `--force`, `--debug`
### API Communication
Core API functions in `cmd/root.go`:
- `Fetch(url string)` - GET requests
- `Post(url, input)` - POST requests
- `Delete(url)` - DELETE requests
All API calls use `Fqdn + "/api/v1/" + url` pattern with Bearer token authentication
### Version Management
- CLI version tracking with auto-update check (10 minute interval)
- API version checking and minimum version enforcement via `CheckMinimumVersion()`
- Self-update capability using `go-selfupdate` library
### Output Formatting
Three output modes supported via `--format` flag:
- `table` (default) - tabwriter formatted output
- `json` - compact JSON
- `pretty` - indented JSON
## Development Commands
### Build
```bash
go build -o coolify ./coolify
```
### Run locally
```bash
go run ./coolify [command]
```
### Test a command
```bash
go run ./coolify context list
go run ./coolify servers list --debug
```
### Install locally
```bash
go install ./coolify
```
### Run tests
```bash
# Run all tests (tests are in internal/ directory)
go test ./internal/...
# Run with coverage
go test ./internal/... -cover
# Run with verbose output
go test ./internal/... -v
# Run specific package
go test ./internal/api/... -v
go test ./internal/service/... -v
# Run specific test
go test ./internal/api -run TestClient_Get_Success -v
```
### Before committing
```bash
# 1. Run tests
go test ./internal/...
# 2. Check coverage
go test ./internal/... -cover
# 3. Run linter (if available)
golangci-lint run
# 4. Format code
go fmt ./...
```
## Release Process
- Uses GoReleaser for multi-platform builds (Linux, Darwin, Windows on amd64/arm64)
- Release workflow: `.github/workflows/release-cli.yml` triggers on GitHub releases
- GoReleaser config: `.goreleaser.yml`
- Install script: `scripts/install.sh` downloads from GitHub releases
## Key Patterns
### Adding a New Command
1. Create new file in `cmd/` (e.g., `cmd/newfeature.go`)
2. Define command struct with cobra.Command
3. Implement Run function with:
- Call `CheckDefaultThings(nil)` to validate version and format
- Use `Fetch()`, `Post()`, or `Delete()` helpers
- Handle JSON unmarshaling into typed structs
- Support all three output formats
4. Register command in `init()` function: `rootCmd.AddCommand(yourCmd)`
### API Version Requirements
If a command requires a specific Coolify API version, pass it to `CheckDefaultThings()`:
```go
minimumVersion := "4.0.0"
CheckDefaultThings(&minimumVersion)
```
### Handling Sensitive Data
- Use `ShowSensitive` flag to control display of tokens/secrets
- Default overlay: `SensitiveInformationOverlay = "********"`
### UUID vs ID Pattern
**CRITICAL: Always use UUIDs for user-facing interactions, never internal database IDs.**
When adding new commands or models:
1. **Command Arguments**: Always accept UUIDs as string arguments (e.g., `<resource_uuid>`), never integer IDs
2. **API Endpoints**: Construct API paths using UUIDs (e.g., `resources/{uuid}`), not IDs
3. **Service Layer**: Methods should accept `uuid string` parameters, not `id int`
4. **Table Output**: Hide internal IDs from table output using `table:"-"` struct tags
5. **Model Fields**:
- Keep `ID int` field with `json:"id" table:"-"` (for API responses, hidden from users)
- Always include `UUID string` field with `json:"uuid"` (visible to users)
**Example model:**
```go
type Resource struct {
ID int `json:"id" table:"-"` // Hidden from table output
UUID string `json:"uuid"` // Shown in table output
Name string `json:"name"`
// ... other fields
}
```
**Why UUIDs?**
- UUIDs are stable across environments (dev, staging, prod)
- IDs are internal implementation details that can change
- UUIDs are more secure (don't expose database sequencing)
- Coolify API uses UUIDs as the primary resource identifier
## Testing Requirements
**CRITICAL: All code changes MUST include tests. This is non-negotiable.**
### Test Coverage Requirements
- **Minimum coverage**: 70% for all packages
- **New features**: Must have 80%+ coverage
- **Bug fixes**: Must include regression tests
- **Refactoring**: Must maintain or improve existing coverage
### Testing Structure
```
test/
├── fixtures/ # Test data, mock API responses
├── mocks/ # Mock implementations of interfaces
└── integration/ # Integration tests with test server
```
### Test Requirements by Package Type
#### 1. Command Tests (`cmd/*_test.go`)
- Test command parsing and flag handling
- Test output formatting (table, json, pretty)
- Use mock API client to avoid real API calls
- Test error handling and validation
- Example:
```go
func TestServersListCmd(t *testing.T) {
// Test with mock client
// Verify output format
// Test error cases
}
```
#### 2. API Client Tests (`internal/api/*_test.go`)
- Test request building
- Test response parsing
- Test error handling (4xx, 5xx status codes)
- Test retry logic
- Test timeout behavior
- **IMPORTANT**: Use `httptest.NewServer()` for mock HTTP responses (NOT real APIs)
- All API tests must use local mock servers, never call real Coolify cloud or external APIs
#### 3. Service Tests (`internal/service/*_test.go`)
- Test business logic
- Mock API client
- Test complex workflows
- Test error propagation
#### 4. Model Tests (`internal/models/*_test.go`)
- Test JSON marshaling/unmarshaling
- Test validation logic
- Test helper methods
#### 5. Integration Tests (`test/integration/*_test.go`)
- Test full command execution
- Test with real HTTP server (httptest)
- Test config file operations
- Test version checking
- Can be run with `-short` flag to skip
### Running Tests
```bash
# Run all tests (tests are in internal/ directory)
go test ./internal/...
# Run with coverage
go test ./internal/... -cover
# Generate coverage report
go test ./internal/... -coverprofile=coverage.out
go tool cover -html=coverage.out
# Run with verbose output
go test ./internal/... -v
# Run only unit tests (skip integration)
go test ./internal/... -short
# Run specific package
go test ./internal/api/... -v
go test ./internal/service/... -v
```
### Test Guidelines
1. **Table-driven tests**: Use for testing multiple scenarios
2. **Test naming**: `TestFunctionName_Scenario_ExpectedBehavior`
3. **Subtests**: Use `t.Run()` for related test cases
4. **Setup/Teardown**: Use `TestMain()` for package-level setup
5. **Parallel tests**: Use `t.Parallel()` when tests are independent
6. **Mock dependencies**: Never call real APIs in unit tests
7. **Test fixtures**: Store mock API responses in `test/fixtures/`
### Example Test Structure
```go
func TestServersList(t *testing.T) {
tests := []struct {
name string
response string
wantErr bool
wantCount int
}{
{
name: "successful list",
response: readFixture("servers_list.json"),
wantErr: false,
wantCount: 3,
},
{
name: "empty list",
response: "[]",
wantErr: false,
wantCount: 0,
},
{
name: "api error",
response: `{"error":"unauthorized"}`,
wantErr: true,
wantCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test implementation
})
}
}
```
### When Adding a New Command
**CHECKLIST** (must complete ALL items):
- [ ] Create command implementation in `cmd/`
- [ ] Create corresponding test file in `internal/service/*_test.go` or `internal/api/*_test.go`
- [ ] Test all flags and arguments
- [ ] Test all output formats (table, json, pretty)
- [ ] Test error cases (missing args, API errors, invalid input)
- [ ] Add integration test if command has complex workflow
- [ ] Update README.md with command documentation
- [ ] Run `go test ./internal/...` and ensure all tests pass
- [ ] Verify coverage: `go test ./internal/... -cover`
### CI/CD Integration
Tests run automatically on:
- Every pull request
- Every commit to main branch
- Before releases
**Pull requests will be blocked if:**
- Any test fails
- Coverage drops below 70%
- New code has no tests
## .cursorrules Context
The project follows Go 1.22+ idioms with standard library preference:
- Use `net/http` standard library (no external HTTP frameworks)
- Leverage Go 1.22 ServeMux features for any routing needs
- Follow RESTful patterns for API interactions
- Implement proper error handling with custom types when needed
- Use Go's concurrency features appropriately
- Write secure, efficient, and maintainable code
- **ALWAYS write tests** - see Testing Requirements section above
+620
View File
@@ -0,0 +1,620 @@
# Contributing to Coolify CLI
Thank you for your interest in contributing to the Coolify CLI! This document provides guidelines and instructions for contributing to the project.
## Table of Contents
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Project Architecture](#project-architecture)
- [Adding a New Command](#adding-a-new-command)
- [Testing Requirements](#testing-requirements)
- [Code Style & Conventions](#code-style--conventions)
- [Submitting Changes](#submitting-changes)
## Getting Started
Before you start contributing:
1. **Read the [ARCHITECTURE.md](ARCHITECTURE.md)** for detailed architectural guidance
2. **Review the [OpenAPI specification](https://github.com/coollabsio/coolify/blob/v4.x/openapi.json)** to understand available API endpoints
3. **Check existing issues** to see if your feature/bug is already being worked on
4. **Open an issue** to discuss your proposed changes (for large features)
### Prerequisites
- Go 1.24 or higher
- Git
## Development Setup
### Clone and Build
```bash
# Fork the repository on GitHub
# Clone your fork
git clone https://github.com/YOUR_USERNAME/coolify-cli.git
cd coolify-cli
# Build the CLI
go build -o coolify ./coolify
# Install locally
go install
```
### Running the CLI
```bash
# Run without installing
go run ./coolify [command]
# Example commands
go run ./coolify context list
go run ./coolify server list --debug
# With flags
go run ./coolify server list --format json --debug
```
### Project Structure
```
cmd/ # CLI commands (organized by feature)
├── root.go # Root command and global flags
├── application/ # Application management commands
├── context/ # Manage Coolify instances
├── server/ # Server management
├── project/ # Project management
├── database/ # Database management
├── deployment/ # Deployment operations
├── service/ # Service management
└── ...
internal/ # Internal packages
├── api/ # API client (HTTP communication)
├── cli/ # CLI utilities (GetAPIClient helper)
├── config/ # Configuration management
├── models/ # Data models and structs
├── output/ # Output formatters (table, json, pretty)
├── parser/ # Input parsing utilities
├── service/ # Business logic layer
└── version/ # Version management
test/ # Test utilities and fixtures
└── fixtures/ # Mock API response data
```
## Project Architecture
The Coolify CLI follows a **layered architecture**:
```
User → Commands (cmd/) → Services (internal/service/) → API Client (internal/api/) → Coolify API
```
### Layer Responsibilities
1. **Command Layer** (`cmd/`)
- Parse CLI arguments and flags
- Call service layer methods
- Format output using output formatters
2. **Service Layer** (`internal/service/`)
- Business logic
- Coordinate API calls
- Transform data
3. **API Client Layer** (`internal/api/`)
- HTTP communication
- Retry logic with exponential backoff
- Authentication (Bearer tokens)
- Error handling
### Key Dependencies
- **cobra**: CLI framework
- **viper**: Configuration management
- **stretchr/testify**: Testing assertions
## Adding a New Command
Follow these steps to add a new command:
### 1. Create Command Directory Structure
```bash
# Create directory for your command
mkdir -p cmd/myfeature
```
### 2. Create Parent Command
Create `cmd/myfeature/myfeature.go`:
```go
package myfeature
import "github.com/spf13/cobra"
// NewMyFeatureCommand creates the myfeature parent command
func NewMyFeatureCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "myfeature",
Aliases: []string{"mf"},
Short: "MyFeature related commands",
Long: `Manage MyFeature resources.`,
}
// Add subcommands
cmd.AddCommand(NewListCommand())
cmd.AddCommand(NewGetCommand())
// ... more subcommands
return cmd
}
```
### 3. Create Subcommand
Create `cmd/myfeature/list.go`:
```go
package myfeature
import (
"context"
"fmt"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
"github.com/spf13/cobra"
)
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all myfeature resources",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
// Get API client
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
// Use service layer
svc := service.NewMyFeatureService(client)
items, err := svc.List(ctx)
if err != nil {
return fmt.Errorf("failed to list items: %w", err)
}
// Format output
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return err
}
return formatter.Format(items)
},
}
}
```
### 4. Create Service Layer
Create `internal/service/myfeature.go`:
```go
package service
import (
"context"
"github.com/coollabsio/coolify-cli/internal/api"
"github.com/coollabsio/coolify-cli/internal/models"
)
type MyFeatureService struct {
client *api.Client
}
func NewMyFeatureService(client *api.Client) *MyFeatureService {
return &MyFeatureService{client: client}
}
func (s *MyFeatureService) List(ctx context.Context) ([]models.MyFeature, error) {
var items []models.MyFeature
err := s.client.Get(ctx, "myfeature", &items)
return items, err
}
func (s *MyFeatureService) Get(ctx context.Context, uuid string) (*models.MyFeature, error) {
var item models.MyFeature
err := s.client.Get(ctx, "myfeature/"+uuid, &item)
return &item, err
}
func (s *MyFeatureService) Create(ctx context.Context, req models.MyFeatureCreateRequest) (*models.Response, error) {
var response models.Response
err := s.client.Post(ctx, "myfeature", req, &response)
return &response, err
}
func (s *MyFeatureService) Delete(ctx context.Context, uuid string) error {
return s.client.Delete(ctx, "myfeature/"+uuid)
}
```
### 5. Create Models
Create `internal/models/myfeature.go`:
```go
package models
type MyFeature struct {
ID int `json:"id" table:"-"` // Hidden from table output
UUID string `json:"uuid"` // Shown to users
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
// Add more fields...
}
type MyFeatureCreateRequest struct {
Name string `json:"name"`
Description string `json:"description"`
}
```
**Important**: Always use `UUID` for user-facing identifiers, not database `ID`. Hide `ID` field from table output using `table:"-"` tag.
### 6. Register Command
Add your command to `cmd/root.go`:
```go
import (
// ... existing imports
"github.com/coollabsio/coolify-cli/cmd/myfeature"
)
func init() {
// ... existing code
rootCmd.AddCommand(myfeature.NewMyFeatureCommand())
}
```
### 7. Create Tests
Create `internal/service/myfeature_test.go`:
```go
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/coollabsio/coolify-cli/internal/api"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMyFeatureService_List(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/myfeature", r.URL.Path)
assert.Equal(t, "GET", r.Method)
items := []models.MyFeature{
{UUID: "uuid-1", Name: "item-1"},
{UUID: "uuid-2", Name: "item-2"},
}
json.NewEncoder(w).Encode(items)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewMyFeatureService(client)
items, err := svc.List(cmd.Context())
require.NoError(t, err)
assert.Len(t, items, 2)
assert.Equal(t, "uuid-1", items[0].UUID)
}
```
### 8. Update Documentation
- Add command documentation to `README.md`
- Include usage examples and flag descriptions
## Testing Requirements
**All code changes MUST include tests.** This is non-negotiable.
### Coverage Requirements
- **Minimum coverage**: 70% for all packages
- **New features**: 80%+ coverage required
- **Bug fixes**: Must include regression tests
- **Refactoring**: Must maintain or improve existing coverage
### Running Tests
```bash
# Run all tests
go test ./internal/...
# Run with coverage
go test ./internal/... -cover
# Run specific package
go test ./internal/service/... -v
# Run specific test
go test ./internal/service -run TestServerService_List -v
# Generate coverage report
go test ./internal/... -coverprofile=coverage.out
go tool cover -html=coverage.out
```
### Writing Tests
#### Use Table-Driven Tests
```go
func TestMyFunction(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
{
name: "successful case",
input: "test",
want: "expected",
wantErr: false,
},
{
name: "error case",
input: "",
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := MyFunction(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("MyFunction() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("MyFunction() = %v, want %v", got, tt.want)
}
})
}
}
```
#### Mock HTTP Requests
**IMPORTANT**: Never call real APIs in tests. Use `httptest.NewServer()`:
```go
func TestServiceMethod(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request
assert.Equal(t, "/api/v1/endpoint", r.URL.Path)
assert.Equal(t, "GET", r.Method)
// Return mock response
response := models.MyResponse{Data: "test"}
json.NewEncoder(w).Encode(response)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
// ... test your service
}
```
### Test Guidelines
- **Test naming**: `TestFunctionName_Scenario_ExpectedBehavior`
- **Use subtests**: `t.Run()` for related test cases
- **Use testify**: `require.NoError()` for must-pass assertions, `assert.Equal()` for comparisons
- **Mock HTTP**: Use `httptest.NewServer()` for all API tests
- **Test contexts**: Always pass `context.Background()` in tests
- **Test errors**: Verify error messages and types
## Code Style & Conventions
### Go Standards
- Follow standard Go idioms and conventions
- Use `gofmt` for code formatting
- Run `go vet` to catch common issues
- Prefer standard library over external dependencies
### Project Conventions
#### API Client Usage
```go
// Create client (usually done via cli.GetAPIClient())
client := api.NewClient(baseURL, token, api.WithDebug(true))
// GET request
var result MyStruct
err := client.Get(ctx, "endpoint", &result)
// POST request
err := client.Post(ctx, "endpoint", requestBody, &result)
// DELETE request
err := client.Delete(ctx, "endpoint")
// PATCH request
err := client.Patch(ctx, "endpoint", requestBody, &result)
```
#### Service Layer Pattern
```go
type MyService struct {
client *api.Client
}
func NewMyService(client *api.Client) *MyService {
return &MyService{client: client}
}
func (s *MyService) List(ctx context.Context) ([]models.Item, error) {
var items []models.Item
err := s.client.Get(ctx, "items", &items)
return items, err
}
```
#### Error Handling
```go
// Wrap errors with context
if err != nil {
return fmt.Errorf("failed to fetch data: %w", err)
}
// Check and handle specific error types
if apiErr, ok := err.(*api.Error); ok {
if apiErr.StatusCode == 404 {
return fmt.Errorf("resource not found")
}
}
```
#### Global Flags
All commands automatically inherit these global flags:
- `--format` (table|json|pretty) - Output format
- `--show-sensitive` - Show sensitive information
- `--debug` - Enable debug mode
- `--context` - Use specific context by name
- `--token` - Override context token
Access flags in commands:
```go
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
debug, _ := cmd.Flags().GetBool("debug")
```
## Submitting Changes
### Before Committing
```bash
# 1. Format code
go fmt ./...
# 2. Run tests
go test ./internal/...
# 3. Check coverage
go test ./internal/... -cover
# 4. Run vet
go vet ./...
```
### Commit Messages
Write clear, descriptive commit messages following conventional commits format:
```
<type>: <short summary>
<detailed description>
<footer>
```
Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
Example:
```
feat: add server domains list command
- Implement GET /servers/{uuid}/domains endpoint
- Add server domains subcommand
- Include tests for domain listing
- Update README with new command documentation
```
### Pull Requests
1. **Fork** the repository
2. **Create a branch** from `v4.x`: `git checkout -b feature/my-feature v4.x`
3. **Make your changes** with tests
4. **Push** to your fork: `git push origin feature/my-feature`
5. **Open a pull request** against the `v4.x` branch
6. **Describe your changes** clearly in the PR description
7. **Link related issues** using "Fixes #123" or "Closes #123"
### PR Checklist
- [ ] Tests pass locally (`go test ./internal/...`)
- [ ] Code coverage meets requirements (70%+ minimum)
- [ ] Code is formatted (`go fmt ./...`)
- [ ] README.md updated (if adding new commands)
- [ ] CLAUDE.md updated (if changing architecture)
- [ ] Commit messages are descriptive
- [ ] PR description explains the changes
- [ ] All global flags are supported (format, show-sensitive, debug)
- [ ] Used UUIDs (not IDs) for resource identifiers
## Release Process (not for contributors :) )
Releases are automated using GoReleaser:
1. Tag a new version: `git tag v1.2.3`
2. Push the tag: `git push origin v1.2.3`
3. Create a GitHub release
4. GoReleaser builds binaries for all platforms automatically
## Getting Help
- **Discord**: https://coolify.io/discord
- **Issues**: [Open an issue](https://github.com/coollabsio/coolify-cli/issues) for bugs or feature requests
- **Architecture**: Read [ARCHITECTURE.md](ARCHITECTURE.md) for detailed design documentation
- **API Reference**: See the [OpenAPI specification](https://github.com/coollabsio/coolify/blob/v4.x/openapi.json)
- **Code Guidance**: See [CLAUDE.md](CLAUDE.md) for AI assistant guidance
## License
By contributing, you agree that your contributions will be licensed under the same license as the project.
---
Thank you for contributing to Coolify CLI! 🚀
+123
View File
@@ -0,0 +1,123 @@
# How to Release Coolify CLI
This guide explains the release process for the Coolify CLI.
## Prerequisites
- Write access to the `coollabsio/coolify-cli` repository
- All changes merged to the target branch (`v4.x`)
- All tests passing (`go test ./internal/...`)
## Release Process
### 1. Create a GitHub Release
1. Go to https://github.com/coollabsio/coolify-cli/releases/new
2. Click "Choose a tag" and create a new tag:
- **Tag name**: `v1.x.x` (must start with `v`, e.g., `v1.2.3`)
- **Target**: `v4.x` (or your target branch)
3. **Release title**: `v1.x.x` (same as tag name)
4. **Description**: Write release notes describing:
- New features
- Bug fixes
- Breaking changes (if any)
- Example:
```markdown
## What's New
- Added support for database management
- Improved error messages for API failures
## Bug Fixes
- Fixed panic when config file is missing
## Breaking Changes
- None
```
5. Click "Publish release"
### 2. Automated Build Process
Once you publish the release:
1. GitHub Actions automatically triggers the `release-cli.yml` workflow
2. GoReleaser builds binaries for:
- **Linux**: amd64, arm64
- **macOS (Darwin)**: amd64, arm64
- **Windows**: amd64, arm64
3. Goreleaser injects the version from the tag into the binaries
4. Binaries are automatically uploaded to the release
5. The release becomes available at:
- GitHub: `https://github.com/coollabsio/coolify-cli/releases/tag/v1.x.x`
- Install script: `curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash`
- `go install`: `go install github.com/coollabsio/coolify-cli/coolify@v1.x.x`
### 3. Verify the Release
After the workflow completes (usually 2-5 minutes):
1. Check the release page has all platform binaries
2. Test the install script:
```bash
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
coolify version
```
3. Test the auto-update functionality:
```bash
# If you have an older version installed
coolify update
coolify version # Should show the new version
```
4. Verify the version matches your release
## Troubleshooting
### Build Failed
- Check the GitHub Actions logs at https://github.com/coollabsio/coolify-cli/actions
- Common issues:
- Syntax errors in Go code
- Test failures
- GoReleaser configuration issues
### Version Not Updating
- Ensure you committed the version change in `cmd/root.go`
- The tag must start with `v` (e.g., `v1.2.3`, not `1.2.3`)
- Check that the workflow has write permissions
### Install Script Not Finding New Version
- Wait a few minutes for GitHub's CDN to update
- Check that binaries were uploaded to the release
- Verify the tag format is correct (`v1.x.x`)
## Release Checklist
Before creating a release:
- [ ] All tests pass: `go test ./internal/...`
- [ ] Code is formatted: `go fmt ./...`
- [ ] Version updated in `cmd/root.go`
- [ ] Changes merged to `v4.x` branch
- [ ] Release notes prepared
After creating a release:
- [ ] GitHub Actions workflow completed successfully
- [ ] All platform binaries are present on the release page
- [ ] Install script downloads the new version
- [ ] `coolify version` returns the correct version
## Configuration Files
The release process uses these configuration files:
- `.goreleaser.yml` - GoReleaser configuration (build matrix, archives, etc.) - points to `/coolify` as entry point
- `.github/workflows/release-cli.yml` - GitHub Actions workflow
- `scripts/install.sh` - User-facing install script
- `internal/version/checker.go` - Contains `GetVersion()` function that returns the current version
- `coolify/main.go` - Binary entry point for `go install` support
## Notes
- The CLI has auto-update checking built-in (checks every 10 minutes)
- Users can manually update with `coolify update`
- Install script supports version pinning: `bash install.sh v1.2.3`
- Releases are immutable - if you need to fix something, create a new patch version
+472 -72
View File
@@ -1,100 +1,500 @@
# CLI for [Coolify](https://coolify.io) API
> [!WARNING]
> Until version 1.0.0, the CLI should be considered unstable. Any minor or patch release may introduce breaking changes. Please read the release notes carefully before updating.
## Installation
```bash
curl -fsSL https://raw.githubusercontent.com/coollabsio/cli-coolify/main/scripts/install.sh | bash
```
This will install the CLI in `/usr/local/bin/coolify`.
> If you are a Windows or macOS user, please test the installation script and let us know if it works for you.
## Initial Setup
Before using any commands, you need to initialize the CLI by creating a configuration file:
### Install script (recommended)
```bash
coolify init
curl -fsSL https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.sh | bash
```
This interactive wizard will guide you through setting up your Coolify instance(s). You can choose to:
- Connect to Coolify Cloud using your API token
- Add self-hosted Coolify instance(s) with their FQDN and token
Alternatively, you can generate a default configuration non-interactively:
It will install the CLI in `/usr/local/bin/coolify` and the configuration file in `~/.config/coolify/config.json`
### Using `go install`
```bash
coolify init --default
go install github.com/coollabsio/coolify-cli/coolify@latest
```
The configuration will be stored in `~/.config/coolify/config.json`.
This will install the `coolify` binary in your `$GOPATH/bin` directory (usually `~/go/bin`). Make sure this directory is in your `$PATH`.
## Getting Your API Token
### Using the install script
To use the CLI, you'll need an API token:
1. Log in to your Coolify dashboard (Cloud or self-hosted)
2. Navigate to `/security/api-tokens`
3. Create a new token with appropriate permissions
4. Use this token when initializing the CLI or adding a new instance
## Managing Instances
## Getting Started
1. Get a `<token>` from your Coolify dashboard (Cloud or self-hosted) at `/security/api-tokens`
After initialization, you can manage your Coolify instances:
### Cloud
### Add a New Instance
2. Add the token with `coolify context set-token cloud <token>`
```bash
coolify instances add MyInstance https://my.instance.tld mytoken
```
### Self-hosted
Or use the interactive mode:
2. Add the token with `coolify context add -d <context_name> <url> <token>`
```bash
coolify instances add
```
> Replace `<context_name>` with the name you want to give to the context.
>
> Replace `<url>` with the fully qualified domain name of your Coolify instance.
### List All Instances
```bash
coolify instances list
```
### Set Default Instance
```bash
coolify instances set default MyInstance
```
### Remove an Instance
```bash
coolify instances remove MyInstance
```
### Update Instance Token
```bash
coolify instances set token MyInstance newtoken
```
Now you can use the CLI with the token you just added.
## 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
### Update
- `coolify update` - Update the CLI to the latest version
### Instances
- `coolify instances list` - List all instances
- `coolify instances add` - Create a new instance configuration
- `coolify instances remove` - Remove an instance configuration
- `coolify instances get` - Get an instance configuration
- `coolify instances set <default>|<token>` - Set an instance as default or set a token for an instance
- `coolify instances version` - Get the version of the Coolify API for an instance
### Configuration
- `coolify config` - Show configuration file location
### Shell Completion
- `coolify completion <shell>` - Generate shell completion script
- Supported shells: `bash`, `zsh`, `fish`, `powershell`
### Context Management
- `coolify context list` - List all configured contexts
- `coolify context add <context_name> <url> <token>` - Add a new context
- `-d, --default` - Set as default context
- `-f, --force` - Force overwrite if context already exists
- `coolify context delete <context_name>` - Delete a context
- `coolify context get <context_name>` - Get details of a specific context
- `coolify context set-token <context_name> <token>` - Update the API token for a context
- `coolify context set-default <context_name>` - Set a context as the default
- `coolify context update <context_name>` - Update a context's properties
- `--name <new_name>` - Change the context name
- `--url <new_url>` - Change the context URL
- `--token <new_token>` - Change the context token
- `coolify context use <context_name>` - Switch to a different context (set as default)
- `coolify context verify` - Verify current context connection and authentication
- `coolify context version` - Get the Coolify API version of the current context
### Servers
- `coolify servers list` - List all servers
- `coolify servers get` - Get a server
- `--resources` - Get the resources and their status of a server
Commands can use `server` or `servers` interchangeably.
- `coolify server list` - List all servers
- `coolify server get <uuid>` - Get a server by UUID
- `--resources` - Get the resources and their status of a server
- `coolify server add <name> <ip> <private_key_uuid>` - Add a new server
- `-p, --port <port>` - SSH port (default: 22)
- `-u, --user <user>` - SSH user (default: root)
- `--validate` - Validate server immediately after adding
- `coolify server remove <uuid>` - Remove a server
- `coolify server validate <uuid>` - Validate a server connection
- `coolify server domains <uuid>` - Get server domains by UUID
### Projects
- `coolify projects list` - List all projects
- `coolify projects get <uuid>` - Get project environments
### Resources
- `coolify resources list` - List all resources
### Applications
- `coolify app list` - List all applications
- `coolify app get <uuid>` - Get application details
- `coolify app update <uuid>` - Update application configuration
- `--name <name>` - Application name
- `--description <description>` - Application description
- `--git-branch <branch>` - Git branch
- `--git-repository <url>` - Git repository URL
- `--domains <domains>` - Domains (comma-separated)
- `--build-command <cmd>` - Build command
- `--start-command <cmd>` - Start command
- `--install-command <cmd>` - Install command
- `--base-directory <path>` - Base directory
- `--publish-directory <path>` - Publish directory
- `--dockerfile <content>` - Dockerfile content
- `--docker-image <image>` - Docker image name
- `--docker-tag <tag>` - Docker image tag
- `--ports-exposes <ports>` - Exposed ports
- `--ports-mappings <mappings>` - Port mappings
- `--health-check-enabled` - Enable health check
- `--health-check-path <path>` - Health check path
- `coolify app delete <uuid>` - Delete an application
- `-f, --force` - Skip confirmation prompt
- `coolify app start <uuid>` - Start an application
- `coolify app stop <uuid>` - Stop an application
- `coolify app restart <uuid>` - Restart an application
- `coolify app logs <uuid>` - Get application logs
#### Application Environment Variables
- `coolify app env list <app_uuid>` - List all environment variables
- `coolify app env get <app_uuid> <env_uuid_or_key>` - Get a specific environment variable
- `coolify app env create <app_uuid>` - Create a new environment variable
- `--key <key>` - Variable key (required)
- `--value <value>` - Variable value (required)
- `--preview` - Available in preview deployments
- `--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> <env_uuid>` - Update an environment variable
- `coolify app env delete <app_uuid> <env_uuid>` - Delete an environment variable
- `coolify app env sync <app_uuid>` - Sync environment variables from a .env file
- `--file <path>` - Path to .env file (required)
- `--build-time` - Make all variables available at build time
- `--preview` - Make all variables available in preview deployments
- `--is-literal` - Treat all values as literal (don't interpolate variables)
- **Behavior**: Updates existing variables, creates missing ones. Does NOT delete variables not in the file.
#### Application Deployments
- `coolify app deployments list <app-uuid>` - List all deployments for an application
- `coolify app deployments logs <app-uuid> [deployment-uuid]` - Get deployment logs (formatted as human-readable text)
- If only `app-uuid` is provided: retrieves logs from the **latest/most recent deployment only**
- If `deployment-uuid` is also provided: retrieves logs for that **specific deployment**
- `-n, --lines <n>` - Number of log lines to display (default: 0 = all lines)
- `-f, --follow` - Follow log output in real-time (like tail -f)
- `--debuglogs` - Show debug logs (includes hidden commands and internal operations)
### Databases
- `coolify database list` - List all databases
- `coolify database get <uuid>` - Get database details
- `coolify database create <type>` - Create a new database
- Supported types: `postgresql`, `mysql`, `mariadb`, `mongodb`, `redis`, `keydb`, `clickhouse`, `dragonfly`
- `--server-uuid <uuid>` - Server UUID (required)
- `--project-uuid <uuid>` - Project UUID (required)
- `--environment-name <name>` - Environment name (required unless using --environment-uuid)
- `--environment-uuid <uuid>` - Environment UUID (required unless using --environment-name)
- `--destination-uuid <uuid>` - Destination UUID if server has multiple destinations
- `--name <name>` - Database name
- `--description <description>` - Database description
- `--image <image>` - Docker image
- `--instant-deploy` - Deploy immediately after creation
- `--is-public` - Make database publicly accessible
- `--public-port <port>` - Public port number
- `--limits-memory <size>` - Memory limit (e.g., '512m', '2g')
- `--limits-cpus <cpus>` - CPU limit (e.g., '0.5', '2')
- Database-specific flags (postgres-user, mysql-root-password, etc.)
- `coolify database update <uuid>` - Update database configuration
- `coolify database delete <uuid>` - Delete a database
- `--delete-configurations` - Delete configurations (default: true)
- `--delete-volumes` - Delete volumes (default: true)
- `--docker-cleanup` - Run docker cleanup (default: true)
- `--delete-connected-networks` - Delete connected networks (default: true)
- `coolify database start <uuid>` - Start a database
- `coolify database stop <uuid>` - Stop a database
- `coolify database restart <uuid>` - Restart a database
#### Database Backups
- `coolify database backup list <database_uuid>` - List all backup configurations
- `coolify database backup create <database_uuid>` - Create a new backup configuration
- `--frequency <cron>` - Backup frequency (cron expression)
- `--enabled` - Enable backup schedule
- `--save-s3` - Save backups to S3
- `--s3-storage-uuid <uuid>` - S3 storage UUID
- `--databases-to-backup <list>` - Comma-separated list of databases to backup
- `--dump-all` - Dump all databases
- `--retention-amount-local <n>` - Number of backups to retain locally
- `--retention-days-local <n>` - Days to retain backups locally
- `--retention-storage-local <size>` - Max storage for local backups (e.g., '1GB', '500MB')
- `--retention-amount-s3 <n>` - Number of backups to retain in S3
- `--retention-days-s3 <n>` - Days to retain backups in S3
- `--retention-storage-s3 <size>` - Max storage for S3 backups (e.g., '1GB', '500MB')
- `--timeout <seconds>` - Backup timeout in seconds
- `--disable-local` - Disable local backup storage
- `coolify database backup update <database_uuid> <backup_uuid>` - Update a backup configuration
- `coolify database backup delete <database_uuid> <backup_uuid>` - Delete a backup configuration
- `coolify database backup trigger <database_uuid> <backup_uuid>` - Trigger an immediate backup
- `coolify database backup executions <database_uuid> <backup_uuid>` - List backup executions
- `coolify database backup delete-execution <database_uuid> <backup_uuid> <execution_uuid>` - Delete a backup execution
### Services
- `coolify service list` - List all services
- `coolify service get <uuid>` - Get service details
- `coolify service start <uuid>` - Start a service
- `coolify service stop <uuid>` - Stop a service
- `coolify service restart <uuid>` - Restart a service
- `coolify service delete <uuid>` - Delete a service
#### Service Environment Variables
- `coolify service env list <service_uuid>` - List all environment variables
- `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> <env_uuid>` - Update an environment variable
- `coolify service env delete <service_uuid> <env_uuid>` - Delete an environment variable
- `coolify service env sync <service_uuid>` - Sync environment variables from a .env file
- `--file <path>` - Path to .env file (required)
- `--build-time` - Make all variables available at build time
- `--preview` - Make all variables available in preview deployments
- `--is-literal` - Treat all values as literal (don't interpolate variables)
- **Behavior**: Updates existing variables, creates missing ones. Does NOT delete variables not in the file.
### Deployments
- `coolify deploy uuid <uuid>` - Deploy a resource by UUID
- `-f, --force` - Force deployment
- `coolify deploy name <name>` - Deploy a resource by name
- `-f, --force` - Force deployment
- `coolify deploy batch <name1,name2,...>` - Deploy multiple resources at once
- `-f, --force` - Force all deployments
- `coolify deploy list` - List all deployments
- `coolify deploy get <uuid>` - Get deployment details
- `coolify deploy cancel <uuid>` - Cancel a deployment
- `-f, --force` - Skip confirmation prompt
### GitHub Apps
- `coolify github list` - List all GitHub App integrations
- `coolify github get <app_uuid>` - Get GitHub App details
- `coolify github create` - Create a new GitHub App integration
- `--name <name>` - GitHub App name (required)
- `--api-url <url>` - GitHub API URL (required, e.g., https://api.github.com)
- `--html-url <url>` - GitHub HTML URL (required, e.g., https://github.com)
- `--app-id <id>` - GitHub App ID (required)
- `--installation-id <id>` - GitHub Installation ID (required)
- `--client-id <id>` - GitHub OAuth Client ID (required)
- `--client-secret <secret>` - GitHub OAuth Client Secret (required)
- `--private-key-uuid <uuid>` - UUID of existing private key (required)
- `--organization <org>` - GitHub organization
- `--custom-user <user>` - Custom user for SSH (default: git)
- `--custom-port <port>` - Custom port for SSH (default: 22)
- `--webhook-secret <secret>` - GitHub Webhook Secret
- `--system-wide` - Is this app system-wide (cloud only)
- `coolify github update <app_uuid>` - Update a GitHub App
- `coolify github delete <app_uuid>` - Delete a GitHub App
- `-f, --force` - Skip confirmation prompt
- `coolify github repos <app_uuid>` - List repositories accessible by a GitHub App
- `coolify github branches <app_uuid> <owner/repo>` - List branches for a repository
### Teams
- `coolify team list` - List all teams
- `coolify team get <team_id>` - Get team details
- `coolify team current` - Get current team
- `coolify team members list [team_id]` - List team members
### Private Keys
Commands can use `private-key`, `private-keys`, `key`, or `keys` interchangeably.
- `coolify private-key list` - List all private keys
- `coolify private-key add <key_name> <private-key>` - Add a new private key
- Use `@filename` to read from file: `coolify private-key add mykey @~/.ssh/id_rsa`
- `coolify private-key remove <uuid>` - Remove a private key
## Global Flags
All commands support these global flags:
- `--context <name>` - Use a specific context instead of default
- `--host <fqdn>` - Override the Coolify instance hostname
- `--token <token>` - Override the authentication token
- `--format <format>` - Output format: `table` (default), `json`, or `pretty`
- `-s, --show-sensitive` - Show sensitive information (tokens, IPs, etc.)
- `-f, --force` - Force operation (skip confirmations)
- `--debug` - Enable debug mode
## Examples
### Multi-Environment Workflows
```bash
# Add multiple contexts
coolify context add prod https://prod.coolify.io <prod-token>
coolify context add staging https://staging.coolify.io <staging-token>
coolify context add dev https://dev.coolify.io <dev-token>
# Set default
coolify context use prod
# Use different contexts
coolify --context=staging servers list
coolify --context=prod deploy name api
coolify --context=dev resources list
# Default context (prod in this case)
coolify servers list
```
### Application Management
```bash
# List all applications
coolify app list
# Get application details
coolify app get <uuid>
# Manage application lifecycle
coolify app start <uuid>
coolify app stop <uuid>
coolify app restart <uuid>
# View application logs
coolify app logs <uuid>
# Environment variables
coolify app env list <uuid>
coolify app env create <uuid> --key API_KEY --value secret123
# Sync from .env file (updates existing, creates new, keeps others unchanged)
coolify app env sync <uuid> --file .env
coolify app env sync <uuid> --file .env.production --build-time --preview
```
### Database Management
```bash
# List databases
coolify database list
# Create a PostgreSQL database
coolify database create postgresql \
--server-uuid <server-uuid> \
--project-uuid <project-uuid> \
--name mydb \
--instant-deploy
# Manage database lifecycle
coolify database start <uuid>
coolify database stop <uuid>
coolify database restart <uuid>
# Backup management
coolify database backup list <database-uuid>
coolify database backup create <database-uuid> \
--frequency "0 2 * * *" \
--enabled \
--save-s3 \
--retention-days-locally 7
coolify database backup trigger <database-uuid> <backup-uuid>
```
### Service Management
```bash
# List services
coolify service list
# Get service details
coolify service get <uuid>
# Manage services
coolify service start <uuid>
coolify service restart <uuid>
# Environment variables (same as applications)
coolify service env sync <uuid> --file .env
```
### Deploy Workflows
```bash
# Deploy single app by name (easier than UUID)
coolify deploy name my-application
# Deploy multiple apps at once
coolify deploy batch api,worker,frontend
# Force deploy with specific context
coolify --context=prod deploy batch api,worker --force
# Traditional UUID deployment still works
coolify deploy uuid abc123-def456-...
# Monitor deployments
coolify deploy list
coolify deploy get <deployment-uuid>
# Cancel a deployment
coolify deploy cancel <deployment-uuid>
```
### GitHub Apps Integration
```bash
# List GitHub Apps
coolify github list
# Create a GitHub App integration
coolify github create \
--name "My GitHub App" \
--api-url "https://api.github.com" \
--html-url "https://github.com" \
--app-id 123456 \
--installation-id 789012 \
--client-id "Iv1.abc123" \
--client-secret "secret" \
--private-key-uuid <key-uuid>
# List repositories accessible by the app
coolify github repos <app-uuid>
# List branches for a repository
coolify github branches <app-uuid> owner/repo
# Delete a GitHub App
coolify github delete <app-uuid>
```
### Team Management
```bash
# List teams
coolify team list
# Get current team
coolify team current
# List team members
coolify team members list
```
### Server Management
```bash
# List servers in production
coolify --context=prod server list
# Add a server with validation
coolify server add myserver 192.168.1.100 <key-uuid> --validate
# Get server details with resources
coolify server get <uuid> --resources
```
## Output Formats
The CLI supports three output formats:
```bash
# Table format (default, human-readable)
coolify server list
# JSON format (for scripts)
coolify server list --format=json
# Pretty JSON (for debugging)
coolify server list --format=pretty
```
## Architecture
This CLI follows a clean architecture with:
- **Service Layer**: Business logic and API interactions
- **Output Layer**: Consistent formatting across all commands
- **Config Layer**: Multi-context configuration management
- **Models Layer**: Type-safe data structures
## Development
```bash
# Build
go build -o coolify ./coolify
# Run tests
go test ./...
# Run with coverage
go test -cover ./...
# Install locally
go install ./coolify
```
## Contributing
Contributions are welcome!
## License
MIT
+45
View File
@@ -0,0 +1,45 @@
package application
import (
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/cmd/application/env"
)
// NewAppCommand creates the app parent command
func NewAppCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "app",
Aliases: []string{"apps", "application", "applications"},
Short: "Application related commands",
Long: `Manage Coolify applications - list, get, create, update, delete, and control application lifecycle.`,
}
// Add main subcommands
cmd.AddCommand(NewListCommand())
cmd.AddCommand(NewGetCommand())
cmd.AddCommand(NewUpdateCommand())
cmd.AddCommand(NewDeleteCommand())
cmd.AddCommand(NewStartCommand())
cmd.AddCommand(NewStopCommand())
cmd.AddCommand(NewRestartCommand())
cmd.AddCommand(NewLogsCommand())
cmd.AddCommand(NewDeploymentsCommand())
// Add env subcommand with its children
envCmd := &cobra.Command{
Use: "env",
Aliases: []string{"envs", "environment"},
Short: "Manage application environment variables",
Long: `List and manage environment variables for applications. All commands require the application UUID first to establish context.`,
}
envCmd.AddCommand(env.NewListEnvCommand())
envCmd.AddCommand(env.NewGetEnvCommand())
envCmd.AddCommand(env.NewCreateEnvCommand())
envCmd.AddCommand(env.NewUpdateEnvCommand())
envCmd.AddCommand(env.NewDeleteEnvCommand())
envCmd.AddCommand(env.NewSyncEnvCommand())
cmd.AddCommand(envCmd)
return cmd
}
+57
View File
@@ -0,0 +1,57 @@
package application
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 <uuid>",
Short: "Delete an application",
Long: `Delete an application. This action cannot be undone.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
force, _ := cmd.Flags().GetBool("force")
if !force {
var response string
fmt.Printf("Are you sure you want to delete application %s? This cannot be undone. (yes/no): ", uuid)
_, err := fmt.Scanln(&response)
if err != nil {
return fmt.Errorf("failed to read input: %w", err)
}
if response != "yes" && response != "y" {
fmt.Println("Delete cancelled.")
return nil
}
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
appSvc := service.NewApplicationService(client)
err = appSvc.Delete(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to delete application: %w", err)
}
fmt.Printf("Application %s deleted successfully.\n", uuid)
return nil
},
}
cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
return cmd
}
+182
View File
@@ -0,0 +1,182 @@
package application
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"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 NewDeploymentsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "deployments",
Short: "Deployment related commands for an application",
Long: `Manage deployments for a specific application. List deployments or view deployment logs.`,
}
cmd.AddCommand(NewListDeploymentsCommand())
cmd.AddCommand(NewLogsDeploymentsCommand())
return cmd
}
func NewListDeploymentsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list <app-uuid>",
Short: "List all deployments for an application",
Long: `Retrieve a list of all deployments for a specific application.`,
Args: cli.ExactArgs(1, "<app-uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
appUUID := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
deploySvc := service.NewDeploymentService(client)
deployments, err := deploySvc.ListByApplication(ctx, appUUID)
if err != nil {
return fmt.Errorf("failed to list deployments: %w", err)
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return err
}
return formatter.Format(deployments)
},
}
return cmd
}
func NewLogsDeploymentsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "logs <app-uuid> [deployment-uuid]",
Short: "Get deployment logs for an application",
Long: `Retrieve deployment logs for a specific application or deployment.
If only app-uuid is provided, retrieves logs from the latest deployment.
If deployment-uuid is also provided, retrieves logs for that specific deployment.
Use --follow to continuously stream new logs.`,
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
appUUID := args[0]
var deploymentUUID string
if len(args) == 2 {
deploymentUUID = args[1]
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
lines, _ := cmd.Flags().GetInt("lines")
follow, _ := cmd.Flags().GetBool("follow")
debugLogs, _ := cmd.Flags().GetBool("debuglogs")
format, _ := cmd.Flags().GetString("format")
deploySvc := service.NewDeploymentService(client)
// Function to get logs based on whether we have a deployment UUID
// Returns raw or formatted based on format flag
getLogs := func() (string, error) {
if deploymentUUID != "" {
return deploySvc.GetLogsByDeploymentWithFormat(ctx, deploymentUUID, debugLogs, format)
}
// Get logs from the latest deployment
// Use take=1 internally to efficiently fetch only the most recent deployment
return deploySvc.GetLogsByApplicationWithFormat(ctx, appUUID, 1, debugLogs, format)
}
if !follow {
logs, err := getLogs()
if err != nil {
return fmt.Errorf("failed to get deployment logs: %w", err)
}
// Apply line limit if specified (only for text output)
if lines > 0 && format == "table" {
logs = limitLogLines(logs, lines)
}
fmt.Print(logs)
return nil
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
lastLogs := ""
logs, err := getLogs()
if err != nil {
return fmt.Errorf("failed to get deployment logs: %w", err)
}
fmt.Print(logs)
lastLogs = logs
for {
select {
case <-sigChan:
fmt.Println("\nStopping log follow...")
return nil
case <-ticker.C:
logs, err := getLogs()
if err != nil {
continue
}
if logs != lastLogs {
if len(logs) > len(lastLogs) && strings.HasPrefix(logs, lastLogs) {
fmt.Print(logs[len(lastLogs):])
} else {
fmt.Print(logs)
}
lastLogs = logs
}
}
}
},
}
cmd.Flags().IntP("lines", "n", 0, "Number of log lines to display (0 = all)")
cmd.Flags().BoolP("follow", "f", false, "Follow log output (like tail -f)")
cmd.Flags().Bool("debuglogs", false, "Show debug logs (includes hidden commands and internal operations)")
return cmd
}
// limitLogLines limits the output to the last N lines
func limitLogLines(logs string, n int) string {
if n <= 0 {
return logs
}
// Trim trailing newline to avoid empty element at the end
logs = strings.TrimRight(logs, "\n")
lines := strings.Split(logs, "\n")
// If we have fewer lines than requested, return all
if len(lines) <= n {
return logs + "\n"
}
// Get the last N lines
lastLines := lines[len(lines)-n:]
return strings.Join(lastLines, "\n") + "\n"
}
+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 NewCreateEnvCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create <app_uuid>",
Short: "Create an environment variable for an application",
Long: `Create a new environment variable for a specific application. Use --key and --value flags to specify the variable.`,
Args: cli.ExactArgs(1, "<app_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
appUUID := 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")
isBuildTime, _ := cmd.Flags().GetBool("build-time")
isPreview, _ := cmd.Flags().GetBool("preview")
isLiteral, _ := cmd.Flags().GetBool("is-literal")
isMultiline, _ := cmd.Flags().GetBool("is-multiline")
if key == "" {
return fmt.Errorf("--key is required")
}
if value == "" {
return fmt.Errorf("--value is required")
}
req := &models.EnvironmentVariableCreateRequest{
Key: key,
Value: value,
}
if cmd.Flags().Changed("build-time") {
req.IsBuildTime = &isBuildTime
}
if cmd.Flags().Changed("preview") {
req.IsPreview = &isPreview
}
if cmd.Flags().Changed("is-literal") {
req.IsLiteral = &isLiteral
}
if cmd.Flags().Changed("is-multiline") {
req.IsMultiline = &isMultiline
}
appSvc := service.NewApplicationService(client)
env, err := appSvc.CreateEnv(ctx, appUUID, 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("UUID: %s\n", env.UUID)
return nil
},
}
cmd.Flags().String("key", "", "Environment variable key (required)")
cmd.Flags().String("value", "", "Environment variable value (required)")
cmd.Flags().Bool("build-time", false, "Available at build time")
cmd.Flags().Bool("preview", false, "Available in preview deployments")
cmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
return cmd
}
+59
View File
@@ -0,0 +1,59 @@
package env
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewDeleteEnvCommand() *cobra.Command {
deleteEnvCmd := &cobra.Command{
Use: "delete <app_uuid> <env_uuid>",
Short: "Delete an environment variable",
Long: `Delete an environment variable from an application. First UUID is the application, 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()
appUUID := 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): ")
_, err := fmt.Scanln(&response)
if err != nil {
return fmt.Errorf("failed to read confirmation: %w", err)
}
if response != "yes" && response != "y" {
fmt.Println("Delete cancelled.")
return nil
}
}
appSvc := service.NewApplicationService(client)
err = appSvc.DeleteEnv(ctx, appUUID, envUUID)
if err != nil {
return fmt.Errorf("failed to delete environment variable: %w", err)
}
fmt.Println("Environment variable deleted successfully.")
return nil
},
}
deleteEnvCmd.Flags().Bool("force", false, "Skip confirmation prompt")
return deleteEnvCmd
}
+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/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewGetEnvCommand() *cobra.Command {
return &cobra.Command{
Use: "get <app_uuid> <env_uuid_or_key>",
Short: "Get environment variable details",
Long: `Get detailed information about a specific environment variable 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]
envUUIDOrKey := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
appSvc := service.NewApplicationService(client)
env, err := appSvc.GetEnv(ctx, appUUID, envUUIDOrKey)
if err != nil {
return fmt.Errorf("failed to get environment variable: %w", err)
}
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
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)
},
}
}
+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 NewListEnvCommand() *cobra.Command {
return &cobra.Command{
Use: "list <app_uuid>",
Short: "List all environment variables for an application",
Long: `List all environment variables for a specific application.`,
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)
}
appSvc := service.NewApplicationService(client)
envs, err := appSvc.ListEnvs(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to list environment variables: %w", err)
}
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
if !showSensitive {
for i := range envs {
envs[i].Value = "********"
if envs[i].RealValue != nil {
masked := "********"
envs[i].RealValue = &masked
}
}
}
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return err
}
return formatter.Format(envs)
},
}
}
+154
View File
@@ -0,0 +1,154 @@
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 NewSyncEnvCommand() *cobra.Command {
syncEnvCmd := &cobra.Command{
Use: "sync <app_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 app 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")
}
isBuildTime, _ := cmd.Flags().GetBool("build-time")
isPreview, _ := cmd.Flags().GetBool("preview")
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
appSvc := service.NewApplicationService(client)
existingEnvs, err := appSvc.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.EnvironmentVariable)
for _, env := range existingEnvs {
existingMap[env.Key] = env
}
// Separate into updates and creates
var toUpdate []models.EnvironmentVariableCreateRequest
var toCreate []models.EnvironmentVariableCreateRequest
for _, envVar := range envVars {
req := models.EnvironmentVariableCreateRequest{
Key: envVar.Key,
Value: envVar.Value,
}
// Apply flags if explicitly provided
if cmd.Flags().Changed("build-time") {
req.IsBuildTime = &isBuildTime
}
if cmd.Flags().Changed("preview") {
req.IsPreview = &isPreview
}
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 := &service.BulkUpdateEnvsRequest{
Data: toUpdate,
}
_, err := appSvc.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 := appSvc.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
},
}
syncEnvCmd.Flags().StringP("file", "f", "", "Path to .env file (required)")
syncEnvCmd.Flags().Bool("build-time", false, "Make all variables available at build time")
syncEnvCmd.Flags().Bool("preview", false, "Make all variables available in preview deployments")
syncEnvCmd.Flags().Bool("is-literal", false, "Treat all values as literal (don't interpolate variables)")
return syncEnvCmd
}
+80
View File
@@ -0,0 +1,80 @@
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 NewUpdateEnvCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <app_uuid> <env_uuid>",
Short: "Update an environment variable",
Long: `Update an existing environment variable. First UUID is the application, second is the specific environment variable to update.`,
Args: cli.ExactArgs(2, "<uuid1> <uuid2>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
appUUID := args[0]
envUUID := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
req := &models.EnvironmentVariableUpdateRequest{
UUID: envUUID,
}
if cmd.Flags().Changed("key") {
key, _ := cmd.Flags().GetString("key")
req.Key = &key
}
if cmd.Flags().Changed("value") {
value, _ := cmd.Flags().GetString("value")
req.Value = &value
}
if cmd.Flags().Changed("build-time") {
isBuildTime, _ := cmd.Flags().GetBool("build-time")
req.IsBuildTime = &isBuildTime
}
if cmd.Flags().Changed("preview") {
isPreview, _ := cmd.Flags().GetBool("preview")
req.IsPreview = &isPreview
}
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 req.Key == nil && req.Value == nil && req.IsBuildTime == nil && req.IsPreview == nil && req.IsLiteral == nil && req.IsMultiline == nil {
return fmt.Errorf("at least one field must be provided to update")
}
appSvc := service.NewApplicationService(client)
env, err := appSvc.UpdateEnv(ctx, appUUID, 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")
cmd.Flags().String("value", "", "New environment variable value")
cmd.Flags().Bool("build-time", false, "Available at build time")
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")
return cmd
}
+47
View File
@@ -0,0 +1,47 @@
package application
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 <uuid>",
Short: "Get application details by UUID",
Long: `Retrieve detailed information about a specific application.`,
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)
}
appSvc := service.NewApplicationService(client)
app, err := appSvc.Get(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to get application: %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(app)
},
}
}
+70
View File
@@ -0,0 +1,70 @@
package application
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/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all applications",
Long: `List all applications in Coolify.`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
appSvc := service.NewApplicationService(client)
apps, err := appSvc.List(ctx)
if err != nil {
return fmt.Errorf("failed to list applications: %w", err)
}
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
// For JSON/pretty formats, return the full application structure
if format != output.FormatTable {
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return err
}
return formatter.Format(apps)
}
// For table format, convert to simplified rows
var rows []models.ApplicationListItem
for _, app := range apps {
rows = append(rows, models.ApplicationListItem{
UUID: app.UUID,
Name: app.Name,
Description: app.Description,
Status: app.Status,
GitBranch: app.GitBranch,
FQDN: app.FQDN,
})
}
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return err
}
return formatter.Format(rows)
},
}
}
+86
View File
@@ -0,0 +1,86 @@
package application
import (
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewLogsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "logs <uuid>",
Short: "Get application logs",
Long: `Retrieve logs for an application. Use --follow to continuously stream new logs.`,
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)
}
lines, _ := cmd.Flags().GetInt("lines")
follow, _ := cmd.Flags().GetBool("follow")
appSvc := service.NewApplicationService(client)
if !follow {
resp, err := appSvc.Logs(ctx, uuid, lines)
if err != nil {
return fmt.Errorf("failed to get logs: %w", err)
}
fmt.Print(resp.Logs)
return nil
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
lastLogs := ""
resp, err := appSvc.Logs(ctx, uuid, lines)
if err != nil {
return fmt.Errorf("failed to get logs: %w", err)
}
fmt.Print(resp.Logs)
lastLogs = resp.Logs
for {
select {
case <-sigChan:
fmt.Println("\nStopping log follow...")
return nil
case <-ticker.C:
resp, err := appSvc.Logs(ctx, uuid, lines)
if err != nil {
continue
}
if resp.Logs != lastLogs {
if len(resp.Logs) > len(lastLogs) && strings.HasPrefix(resp.Logs, lastLogs) {
fmt.Print(resp.Logs[len(lastLogs):])
} else {
fmt.Print(resp.Logs)
}
lastLogs = resp.Logs
}
}
}
},
}
cmd.Flags().IntP("lines", "n", 100, "Number of log lines to retrieve")
cmd.Flags().BoolP("follow", "f", false, "Follow log output (like tail -f)")
return cmd
}
+37
View File
@@ -0,0 +1,37 @@
package application
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewRestartCommand() *cobra.Command {
return &cobra.Command{
Use: "restart <uuid>",
Short: "Restart an application",
Long: `Restart a running application.`,
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)
}
appSvc := service.NewApplicationService(client)
resp, err := appSvc.Restart(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to restart application: %w", err)
}
fmt.Println(resp.Message)
return nil
},
}
}
+48
View File
@@ -0,0 +1,48 @@
package application
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewStartCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "start <uuid>",
Aliases: []string{"deploy"},
Short: "Start an application",
Long: `Start an application (initiates a deployment).`,
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)
}
force, _ := cmd.Flags().GetBool("force")
instantDeploy, _ := cmd.Flags().GetBool("instant-deploy")
appSvc := service.NewApplicationService(client)
resp, err := appSvc.Start(ctx, uuid, force, instantDeploy)
if err != nil {
return fmt.Errorf("failed to start application: %w", err)
}
fmt.Println(resp.Message)
if resp.DeploymentUUID != nil && *resp.DeploymentUUID != "" {
fmt.Printf("Deployment UUID: %s\n", *resp.DeploymentUUID)
}
return nil
},
}
cmd.Flags().Bool("force", false, "Force rebuild")
cmd.Flags().Bool("instant-deploy", false, "Instant deploy (skip queuing)")
return cmd
}
+37
View File
@@ -0,0 +1,37 @@
package application
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewStopCommand() *cobra.Command {
return &cobra.Command{
Use: "stop <uuid>",
Short: "Stop an application",
Long: `Stop a running application.`,
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)
}
appSvc := service.NewApplicationService(client)
resp, err := appSvc.Stop(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to stop application: %w", err)
}
fmt.Println(resp.Message)
return nil
},
}
}
+161
View File
@@ -0,0 +1,161 @@
package application
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/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <uuid>",
Short: "Update application configuration",
Long: `Update configuration for a specific application. Only specified fields will be updated.`,
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)
}
req := models.ApplicationUpdateRequest{}
hasUpdates := false
if cmd.Flags().Changed("name") {
name, _ := cmd.Flags().GetString("name")
req.Name = &name
hasUpdates = true
}
if cmd.Flags().Changed("description") {
desc, _ := cmd.Flags().GetString("description")
req.Description = &desc
hasUpdates = true
}
if cmd.Flags().Changed("git-branch") {
branch, _ := cmd.Flags().GetString("git-branch")
req.GitBranch = &branch
hasUpdates = true
}
if cmd.Flags().Changed("git-repository") {
repo, _ := cmd.Flags().GetString("git-repository")
req.GitRepository = &repo
hasUpdates = true
}
if cmd.Flags().Changed("domains") {
domains, _ := cmd.Flags().GetString("domains")
req.Domains = &domains
hasUpdates = true
}
if cmd.Flags().Changed("build-command") {
buildCmd, _ := cmd.Flags().GetString("build-command")
req.BuildCommand = &buildCmd
hasUpdates = true
}
if cmd.Flags().Changed("start-command") {
startCmd, _ := cmd.Flags().GetString("start-command")
req.StartCommand = &startCmd
hasUpdates = true
}
if cmd.Flags().Changed("install-command") {
installCmd, _ := cmd.Flags().GetString("install-command")
req.InstallCommand = &installCmd
hasUpdates = true
}
if cmd.Flags().Changed("base-directory") {
baseDir, _ := cmd.Flags().GetString("base-directory")
req.BaseDirectory = &baseDir
hasUpdates = true
}
if cmd.Flags().Changed("publish-directory") {
publishDir, _ := cmd.Flags().GetString("publish-directory")
req.PublishDirectory = &publishDir
hasUpdates = true
}
if cmd.Flags().Changed("dockerfile") {
dockerfile, _ := cmd.Flags().GetString("dockerfile")
req.Dockerfile = &dockerfile
hasUpdates = true
}
if cmd.Flags().Changed("docker-image") {
image, _ := cmd.Flags().GetString("docker-image")
req.DockerRegistryImageName = &image
hasUpdates = true
}
if cmd.Flags().Changed("docker-tag") {
tag, _ := cmd.Flags().GetString("docker-tag")
req.DockerRegistryImageTag = &tag
hasUpdates = true
}
if cmd.Flags().Changed("ports-exposes") {
ports, _ := cmd.Flags().GetString("ports-exposes")
req.PortsExposes = &ports
hasUpdates = true
}
if cmd.Flags().Changed("ports-mappings") {
ports, _ := cmd.Flags().GetString("ports-mappings")
req.PortsMappings = &ports
hasUpdates = true
}
if cmd.Flags().Changed("health-check-enabled") {
enabled, _ := cmd.Flags().GetBool("health-check-enabled")
req.HealthCheckEnabled = &enabled
hasUpdates = true
}
if cmd.Flags().Changed("health-check-path") {
path, _ := cmd.Flags().GetString("health-check-path")
req.HealthCheckPath = &path
hasUpdates = true
}
if !hasUpdates {
return fmt.Errorf("no fields to update. Use --help to see available flags")
}
appSvc := service.NewApplicationService(client)
app, err := appSvc.Update(ctx, uuid, req)
if err != nil {
return fmt.Errorf("failed to update application: %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(app)
},
}
cmd.Flags().String("name", "", "Application name")
cmd.Flags().String("description", "", "Application description")
cmd.Flags().String("git-branch", "", "Git branch")
cmd.Flags().String("git-repository", "", "Git repository URL")
cmd.Flags().String("domains", "", "Domains (comma-separated)")
cmd.Flags().String("build-command", "", "Build command")
cmd.Flags().String("start-command", "", "Start command")
cmd.Flags().String("install-command", "", "Install command")
cmd.Flags().String("base-directory", "", "Base directory")
cmd.Flags().String("publish-directory", "", "Publish directory")
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("ports-exposes", "", "Exposed ports")
cmd.Flags().String("ports-mappings", "", "Port mappings")
cmd.Flags().Bool("health-check-enabled", false, "Enable health check")
cmd.Flags().String("health-check-path", "", "Health check path")
return cmd
}
-46
View File
@@ -1,46 +0,0 @@
package ask
import (
"bufio"
"fmt"
"os"
"strings"
)
func PromptYesOrNo(question string, defaultToYes bool) (bool, error) {
r := bufio.NewReader(os.Stdin)
if defaultToYes {
fmt.Fprintf(os.Stderr, "%s [Y/n]: ", question)
} else {
fmt.Fprintf(os.Stderr, "%s [y/N]: ", question)
}
for {
answer, err := r.ReadString('\n')
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
return defaultToYes, err
}
answer = strings.ToLower(strings.TrimSpace(answer))
switch answer {
case "y", "yes":
return true, nil
case "n", "no":
return false, nil
case "":
return defaultToYes, nil
}
fmt.Fprintf(os.Stderr, "Please answer with 'y' or 'n': ")
}
}
func PromptString(question string) (string, error) {
r := bufio.NewReader(os.Stdin)
fmt.Fprintf(os.Stderr, "%s: ", question)
answer, err := r.ReadString('\n')
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
return "", err
}
return strings.TrimSpace(answer), nil
}
-104
View File
@@ -1,104 +0,0 @@
package cliinit
import (
"errors"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type cliInit struct {
coolify runtime.Getter
}
func New(c runtime.Getter) *cliInit {
return &cliInit{
coolify: c,
}
}
var defaultInstances = []coolTypes.Instance{
{
Name: "cloud",
Default: true,
Fqdn: "https://app.coolify.io",
Token: "",
}, {
Name: "localhost",
Fqdn: "http://localhost:8000",
Token: "",
},
}
func (c *cliInit) NewCommand() *cobra.Command {
generateDefault := false
force := false
cmd := &cobra.Command{
Use: "init",
Example: utils.GetCommandExample(`
%[1]s init
%[1]s init --default
%[1]s init --force
`),
Short: "Initialize a new Coolify CLI configuration file",
Long: `
Initialize Coolify CLI by generating a configuration file in the default directory.
`,
SilenceUsage: true,
Args: cobra.NoArgs,
PreRunE: func(cmd *cobra.Command, args []string) error {
if c.coolify().Config.JsonExists && !force {
return errors.New("configuration file already exists. Please use instances command to make further modifications or force flag to regenerate a new configuration file")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if generateDefault {
viper.Set("instances", defaultInstances)
cmd.Println("Configuration file generated with default instances, use the instances command to make further modifications.")
return c.coolify().Save()
}
// Create a channel to receive the instances
result := make(chan []coolTypes.Instance)
p := tea.NewProgram(newInitModel(result))
// Create a done channel to signal when the program is finished
done := make(chan struct{})
var programErr error
// Run the program in a goroutine
go func() {
_, programErr = p.Run()
close(done)
}()
// Wait for either the instances or context cancellation
var instances []coolTypes.Instance
select {
case instances = <-result:
case <-cmd.Context().Done():
return fmt.Errorf("operation cancelled")
case <-done:
if programErr != nil {
return fmt.Errorf("program error: %v", programErr)
}
return fmt.Errorf("program exited without saving instances")
}
viper.Set("instances", instances)
return c.coolify().Save()
},
}
flags := cmd.Flags()
flags.BoolVarP(&generateDefault, "default", "d", false, "Generate a default configuration file (non-interactive)")
flags.BoolVarP(&force, "force", "f", false, "Force the generation of a new configuration file")
return cmd
}
-448
View File
@@ -1,448 +0,0 @@
package cliinit
import (
"errors"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/pkg/tui"
)
var (
checkboxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99"))
checked = checkboxStyle.Render("[x]")
unchecked = checkboxStyle.Render("[ ]")
goldStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true)
)
// initKeyMap defines keybindings for the initialization form
type initKeyMap struct {
Up key.Binding
Down key.Binding
Tab key.Binding
Space key.Binding
Enter key.Binding
Paste key.Binding
Help key.Binding
Quit key.Binding
}
// ShortHelp returns keybindings to be shown in the mini help view
func (k initKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Quit}
}
// FullHelp returns keybindings for the expanded help view
func (k initKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Tab}, // first column
{k.Space, k.Enter, k.Paste, k.Help}, // second column
{k.Quit}, // third column
}
}
var initKeys = initKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "move up"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "move down"),
),
Tab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "next field"),
),
Space: key.NewBinding(
key.WithKeys(" "),
key.WithHelp("space", "toggle checkbox"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "continue"),
),
Paste: key.NewBinding(
key.WithKeys("ctrl+v"),
key.WithHelp("ctrl+v", "paste"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys("esc", "ctrl+c"),
key.WithHelp("esc", "quit"),
),
}
type initModel struct {
instances []coolTypes.Instance
width int
height int
focus int
err error
useCloud bool
useSelfHost bool
cloudToken textinput.Model
selfHostName textinput.Model
selfHostFqdn textinput.Model
selfHostToken textinput.Model
result chan<- []coolTypes.Instance
step int // Current step in the initialization process
tick int // For rainbow effect
keys initKeyMap
help help.Model
}
func newInitModel(result chan<- []coolTypes.Instance) initModel {
cloudToken := textinput.New()
cloudToken.Placeholder = "Enter your Coolify Cloud token"
cloudToken.Prompt = "Cloud Token: "
cloudToken.PromptStyle = tui.FocusedStyle
cloudToken.TextStyle = tui.FocusedStyle
cloudToken.Validate = tui.ValidateNotEmpty
selfHostName := textinput.New()
selfHostName.Placeholder = "Enter name for self-hosted instance"
selfHostName.Prompt = "Name: "
selfHostName.PromptStyle = tui.FocusedStyle
selfHostName.TextStyle = tui.FocusedStyle
selfHostName.Validate = tui.ValidateNotEmpty
selfHostFqdn := textinput.New()
selfHostFqdn.Placeholder = "Enter FQDN for self-hosted instance"
selfHostFqdn.Prompt = "FQDN: "
selfHostFqdn.PromptStyle = tui.FocusedStyle
selfHostFqdn.TextStyle = tui.FocusedStyle
selfHostFqdn.Validate = tui.ValidateFQDN
selfHostToken := textinput.New()
selfHostToken.Placeholder = "Enter token for self-hosted instance"
selfHostToken.Prompt = "Token: "
selfHostToken.PromptStyle = tui.FocusedStyle
selfHostToken.TextStyle = tui.FocusedStyle
selfHostToken.Validate = tui.ValidateNotEmpty
return initModel{
instances: make([]coolTypes.Instance, 0),
focus: 0,
result: result,
step: 0,
cloudToken: cloudToken,
selfHostName: selfHostName,
selfHostFqdn: selfHostFqdn,
selfHostToken: selfHostToken,
keys: initKeys,
help: help.New(),
}
}
func (m initModel) Init() tea.Cmd {
return textinput.Blink
}
func (m initModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.help.ShowAll = !m.help.ShowAll
return m, nil
case key.Matches(msg, m.keys.Space):
// Space toggles checkbox when on step 0 or 2
switch m.step {
case 0:
m.useCloud = !m.useCloud
return m, nil
case 2:
m.useSelfHost = !m.useSelfHost
return m, nil
}
case key.Matches(msg, m.keys.Enter):
switch m.step {
case 0:
// Enter handles progression
if m.useCloud {
m.step++
m.focus = 1
m.cloudToken.Focus()
} else {
m.step += 2
m.focus = 2
}
case 1:
if m.useCloud {
// Check for validation errors
if m.cloudToken.Err != nil {
m.err = m.cloudToken.Err
return m, nil
}
// Manual validation in case field hasn't been edited
if m.cloudToken.Value() == "" {
m.err = errors.New("token is required when using Coolify Cloud")
return m, nil
}
m.step++
m.focus = 2
m.cloudToken.Blur()
}
case 2:
// Enter handles progression
if m.useSelfHost {
m.step++
m.focus = 3
m.selfHostName.Focus()
} else {
// If self-hosted is false, build instances and quit
if m.useCloud {
m.instances = append(m.instances, coolTypes.Instance{
Name: "cloud",
Default: true,
Fqdn: "https://app.coolify.io",
Token: m.cloudToken.Value(),
})
}
// Send instances back to command
if m.result != nil {
m.result <- m.instances
}
return m, tea.Quit
}
case 3:
cloudToken := strings.TrimSpace(m.cloudToken.Value())
if m.useSelfHost {
// Check for validation errors
if m.selfHostName.Err != nil || m.selfHostFqdn.Err != nil || m.selfHostToken.Err != nil {
m.err = errors.New("please fix all field errors before submitting")
return m, nil
}
selfHostName := strings.TrimSpace(m.selfHostName.Value())
selfHostFqdn := strings.TrimSpace(m.selfHostFqdn.Value())
selfHostToken := strings.TrimSpace(m.selfHostToken.Value())
// Manual validation in case fields haven't been edited
if selfHostName == "" {
m.err = errors.New("name is required for self-hosted instance")
return m, nil
}
if selfHostFqdn == "" {
m.err = errors.New("FQDN is required for self-hosted instance")
return m, nil
}
if selfHostToken == "" {
m.err = errors.New("token is required for self-hosted instance")
return m, nil
}
// Build instances array
if m.useCloud {
m.instances = append(m.instances, coolTypes.Instance{
Name: "cloud",
Default: true,
Fqdn: "https://app.coolify.io",
Token: cloudToken,
})
}
m.instances = append(m.instances, coolTypes.Instance{
Name: selfHostName,
Default: !m.useCloud,
Fqdn: selfHostFqdn,
Token: selfHostToken,
})
// Send instances back to command
if m.result != nil {
m.result <- m.instances
}
return m, tea.Quit
} else {
// If self-hosted is false, build instances and quit
if m.useCloud {
m.instances = append(m.instances, coolTypes.Instance{
Name: "cloud",
Default: true,
Fqdn: "https://app.coolify.io",
Token: cloudToken,
})
}
// Send instances back to command
if m.result != nil {
m.result <- m.instances
}
return m, tea.Quit
}
}
case key.Matches(msg, m.keys.Up):
// Only allow up/down navigation when multiple items are visible
if m.step == 3 && m.useSelfHost {
m.focus--
if m.focus < 3 {
m.focus = 5
}
m.updateFocus()
}
case key.Matches(msg, m.keys.Down), key.Matches(msg, m.keys.Tab):
// Only allow up/down navigation when multiple items are visible
if m.step == 3 && m.useSelfHost {
m.focus++
if m.focus > 5 {
m.focus = 3
}
m.updateFocus()
}
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.help.Width = msg.Width
}
// Handle text input updates
if m.step == 1 && m.focus == 1 {
m.cloudToken, cmd = m.cloudToken.Update(msg)
cmds = append(cmds, cmd)
} else if m.step == 3 {
switch m.focus {
case 3:
m.selfHostName, cmd = m.selfHostName.Update(msg)
cmds = append(cmds, cmd)
case 4:
m.selfHostFqdn, cmd = m.selfHostFqdn.Update(msg)
cmds = append(cmds, cmd)
case 5:
m.selfHostToken, cmd = m.selfHostToken.Update(msg)
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
func (m *initModel) updateFocus() {
// Blur all inputs
m.cloudToken.Blur()
m.selfHostName.Blur()
m.selfHostFqdn.Blur()
m.selfHostToken.Blur()
// Focus the selected input
switch m.focus {
case 1:
m.cloudToken.Focus()
case 3:
m.selfHostName.Focus()
case 4:
m.selfHostFqdn.Focus()
case 5:
m.selfHostToken.Focus()
}
}
func (m initModel) View() string {
if m.width == 0 {
return "loading..."
}
var s strings.Builder
// Title
s.WriteString("Initialize Coolify CLI\n\n")
// Step 1: Cloud question
if m.step == 0 {
cloudStyle := tui.BlurredStyle
if m.focus == 0 {
cloudStyle = tui.FocusedStyle
}
s.WriteString(cloudStyle.Render("Do you use "))
s.WriteString(goldStyle.Render("Coolify Cloud?"))
s.WriteString(" ")
if m.useCloud {
s.WriteString(checked)
} else {
s.WriteString(unchecked)
}
s.WriteString("\n")
s.WriteString(tui.BlurredStyle.Render("Hint: use spacebar to toggle checkbox\n"))
}
// Step 2: Cloud token input
if m.step == 1 && m.useCloud {
s.WriteString(m.cloudToken.View())
if m.cloudToken.Err != nil {
// Display validation error next to input
s.WriteString(" ")
s.WriteString(tui.ErrorStyle.Render(m.cloudToken.Err.Error()))
}
s.WriteString("\n")
}
// Step 3: Self-hosted question
if m.step == 2 {
selfHostStyle := tui.BlurredStyle
if m.focus == 2 {
selfHostStyle = tui.FocusedStyle
}
s.WriteString(selfHostStyle.Render("Add self-hosted instance"))
s.WriteString(" ")
if m.useSelfHost {
s.WriteString(checked)
} else {
s.WriteString(unchecked)
}
s.WriteString("\n")
s.WriteString(tui.BlurredStyle.Render("Hint: use spacebar to toggle checkbox\n"))
}
// Step 4: Self-hosted inputs
if m.step == 3 && m.useSelfHost {
// Name input
s.WriteString(m.selfHostName.View())
if m.selfHostName.Err != nil {
// Display validation error next to input
s.WriteString(" ")
s.WriteString(tui.ErrorStyle.Render(m.selfHostName.Err.Error()))
}
s.WriteString("\n\n")
// FQDN input
s.WriteString(m.selfHostFqdn.View())
if m.selfHostFqdn.Err != nil {
// Display validation error next to input
s.WriteString(" ")
s.WriteString(tui.ErrorStyle.Render(m.selfHostFqdn.Err.Error()))
}
s.WriteString("\n\n")
// Token input
s.WriteString(m.selfHostToken.View())
if m.selfHostToken.Err != nil {
// Display validation error next to input
s.WriteString(" ")
s.WriteString(tui.ErrorStyle.Render(m.selfHostToken.Err.Error()))
}
s.WriteString("\n")
}
// Help view
s.WriteString("\n\n")
s.WriteString(m.help.View(m.keys))
// Error message
if m.err != nil {
s.WriteString("\n\n")
s.WriteString(tui.ErrorStyle.Render(m.err.Error()))
}
return s.String()
}
-128
View File
@@ -1,128 +0,0 @@
package cliinstances
import (
"errors"
"fmt"
"slices"
tea "github.com/charmbracelet/bubbletea"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func (c *cliInstances) newAddCommand() *cobra.Command {
force := false
isNewDefault := false
cmd := &cobra.Command{
Use: "add [name] [fqdn] [token]",
Example: utils.GetCommandExample(`
%[1]s instances add MyInstance https://my.instance.tld 1234
%[1]s instances add AnotherInstance https://another.instance.tld 5678 --default
%[1]s instances add MyInstance https://my.instance.tld 91011 --force
%[1]s instances add # Interactive mode
`),
Short: "Add a new instance",
Long: `
Add a new instance to the CLI configuration file.
If no arguments are provided, an interactive form will be shown.
`,
Aliases: []string{"create"},
SilenceUsage: true,
Args: cobra.RangeArgs(0, 3),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return c.runInteractiveMode(cmd, force, isNewDefault)
} else if len(args) != 3 {
return errors.New("command requires either 0 arguments (interactive mode) or exactly 3 arguments (name, fqdn, token)")
}
return c.runNonInteractiveMode(args, force, isNewDefault)
},
}
flags := cmd.Flags()
flags.BoolVarP(&force, "force", "f", false, "Force overwrite existing instance with the same name")
flags.BoolVarP(&isNewDefault, "default", "d", false, "Set this instance as the default instance")
return cmd
}
func (c *cliInstances) runInteractiveMode(cmd *cobra.Command, force, isDefault bool) error {
result := make(chan coolTypes.Instance)
p := tea.NewProgram(newAddModel(result, force, isDefault))
// Create a done channel to signal when the program is finished
done := make(chan struct{})
var programErr error
// Run the program in a goroutine
go func() {
_, programErr = p.Run()
close(done)
}()
// Wait for either the instance or context cancellation
var instance coolTypes.Instance
select {
case instance = <-result:
case <-cmd.Context().Done():
return fmt.Errorf("operation cancelled")
case <-done:
if programErr != nil {
return fmt.Errorf("program error: %v", programErr)
}
return fmt.Errorf("program exited without saving instance")
}
// Check for existing instance with same name
for i, existing := range c.instances {
if existing.Name == instance.Name {
if !force {
return errors.New("instance with the same name already exists. Use the force flag to overwrite or instances set to modify individual attributes")
}
c.instances = slices.Delete(c.instances, i, i+1)
break
}
}
if isDefault {
for i := range c.instances {
c.instances[i].Default = false
}
}
c.instances = append(c.instances, instance)
viper.Set("instances", c.instances)
return c.coolify().Save()
}
func (c *cliInstances) runNonInteractiveMode(args []string, force, isNewDefault bool) error {
// Check for existing instance with same name
for i, instance := range c.instances {
if instance.Name == args[0] {
if !force {
return errors.New("instance with the same name already exists. Use the force flag to overwrite or instances set to modify individual attributes")
}
c.instances = slices.Delete(c.instances, i, i+1)
break
}
}
newInstance := coolTypes.Instance{
Name: args[0],
Fqdn: args[1],
Token: args[2],
Default: isNewDefault,
}
if isNewDefault {
for i := range c.instances {
c.instances[i].Default = false
}
}
c.instances = append(c.instances, newInstance)
viper.Set("instances", c.instances)
return c.coolify().Save()
}
-297
View File
@@ -1,297 +0,0 @@
package cliinstances
import (
"errors"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/pkg/tui"
)
// addKeyMap defines keybindings for the add instance form
type addKeyMap struct {
Up key.Binding
Down key.Binding
Tab key.Binding
Enter key.Binding
Paste key.Binding
Help key.Binding
Quit key.Binding
}
// ShortHelp returns keybindings to be shown in the mini help view
func (k addKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Quit}
}
// FullHelp returns keybindings for the expanded help view
func (k addKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Tab}, // first column
{k.Enter, k.Paste, k.Help}, // second column
{k.Quit}, // third column
}
}
var addKeys = addKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "move up"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "move down"),
),
Tab: key.NewBinding(
key.WithKeys("tab", "shift+tab"),
key.WithHelp("tab", "next field"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "submit/select"),
),
Paste: key.NewBinding(
key.WithKeys("ctrl+v"),
key.WithHelp("ctrl+v", "paste"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys("esc", "ctrl+c"),
key.WithHelp("esc", "quit"),
),
}
type addModel struct {
inputs []textinput.Model
focus int
err error
instance coolTypes.Instance
width int
height int
result chan<- coolTypes.Instance
force bool
isDefault bool
keys addKeyMap
help help.Model
}
func newAddModel(result chan<- coolTypes.Instance, force, isDefault bool) addModel {
// Create text inputs
inputs := make([]textinput.Model, 3)
labels := []string{"Name", "FQDN", "Token"}
for i, label := range labels {
input := textinput.New()
input.Placeholder = fmt.Sprintf("Enter instance %s", label)
input.Prompt = fmt.Sprintf("%s: ", label)
input.PromptStyle = tui.FocusedStyle
input.TextStyle = tui.FocusedStyle
// Set up validation for each input type
switch label {
case "Name":
input.Validate = tui.ValidateNotEmpty
case "FQDN":
input.Validate = tui.ValidateFQDN
case "Token":
input.Validate = tui.ValidateNotEmpty
}
// Focus first input by default
if i == 0 {
input.Focus()
}
inputs[i] = input
}
return addModel{
inputs: inputs,
focus: 0,
result: result,
force: force,
isDefault: isDefault,
keys: addKeys,
help: help.New(),
}
}
func (m addModel) Init() tea.Cmd {
return textinput.Blink
}
func (m addModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.help.ShowAll = !m.help.ShowAll
case key.Matches(msg, m.keys.Enter):
if m.focus == len(m.inputs) {
// Submit - first check if any field has validation errors
for _, input := range m.inputs {
if input.Err != nil {
// Don't proceed if any field has validation errors
m.err = errors.New("please fix all field errors before submitting")
return m, nil
}
}
// Also validate in case fields haven't been edited
if err := m.validateOnSubmit(); err != nil {
m.err = err
return m, nil
}
m.instance = coolTypes.Instance{
Name: strings.TrimSpace(m.inputs[0].Value()),
Fqdn: strings.TrimSpace(m.inputs[1].Value()),
Token: strings.TrimSpace(m.inputs[2].Value()),
Default: m.isDefault,
}
// Return a command to send the instance
return m, func() tea.Msg {
if m.result != nil {
m.result <- m.instance
}
return tea.Quit()
}
} else if m.focus == len(m.inputs)+1 {
// Cancel
return m, tea.Quit
}
// Move to next input
m.focus++
m.updateFocus()
case key.Matches(msg, m.keys.Tab):
if msg.String() == "tab" {
m.focus++
} else {
m.focus--
}
// Wrap around
if m.focus > len(m.inputs)+1 {
m.focus = 0
} else if m.focus < 0 {
m.focus = len(m.inputs) + 1
}
m.updateFocus()
case key.Matches(msg, m.keys.Up):
m.focus--
if m.focus < 0 {
m.focus = len(m.inputs) + 1
}
m.updateFocus()
case key.Matches(msg, m.keys.Down):
m.focus++
if m.focus > len(m.inputs)+1 {
m.focus = 0
}
m.updateFocus()
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.help.Width = msg.Width
}
// Handle text input updates
if m.focus < len(m.inputs) {
var cmd tea.Cmd
m.inputs[m.focus], cmd = m.inputs[m.focus].Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
func (m *addModel) updateFocus() {
// Blur all inputs
for i := range m.inputs {
m.inputs[i].Blur()
}
// Focus current input if it's a text input
if m.focus < len(m.inputs) {
m.inputs[m.focus].Focus()
}
}
// validateOnSubmit handles validation for fields that haven't been edited
func (m addModel) validateOnSubmit() error {
// Trigger validation for all fields
for i, input := range m.inputs {
// If the field hasn't been edited and is empty, it hasn't triggered validation yet
switch i {
case 0:
return tui.ValidateNotEmpty(input.Value())
case 1:
return tui.ValidateFQDN(input.Value())
case 2:
return tui.ValidateNotEmpty(input.Value())
}
}
return nil
}
func (m addModel) View() string {
if m.width == 0 {
return "loading..."
}
var s strings.Builder
// Title
s.WriteString("Add New Instance\n\n")
// Input fields with validation errors
for _, input := range m.inputs {
s.WriteString(input.View())
if input.Err != nil {
// Display the validation error next to the input
s.WriteString(" ")
s.WriteString(tui.ErrorStyle.Render(input.Err.Error()))
}
s.WriteString("\n")
}
// Submit and Cancel buttons
submitStyle := tui.BlurredStyle
if m.focus == len(m.inputs) {
submitStyle = tui.FocusedStyle
}
s.WriteString(submitStyle.Render("Submit"))
s.WriteString(" ")
cancelStyle := tui.BlurredStyle
if m.focus == len(m.inputs)+1 {
cancelStyle = tui.FocusedStyle
}
s.WriteString(cancelStyle.Render("Cancel"))
// Help view at the bottom
s.WriteString("\n\n")
s.WriteString(m.help.View(m.keys))
// General form error message (if any)
if m.err != nil {
s.WriteString("\n\n")
s.WriteString(tui.ErrorStyle.Render(m.err.Error()))
}
return s.String()
}
-48
View File
@@ -1,48 +0,0 @@
package cliinstances
import (
cliinstancesset "github.com/coollabsio/cli-coolify/cmd/cliinstances/set"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type cliInstances struct {
coolify runtime.Getter
instances []coolTypes.Instance
}
func (c *cliInstances) runtime() *runtime.Coolify {
return c.coolify()
}
func New(c runtime.Getter) *cliInstances {
return &cliInstances{
coolify: c,
}
}
func (c *cliInstances) NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "instances",
Short: "Manage CLI instances",
Aliases: []string{"instance"},
Long: `
Manage CLI instances by adding, removing or setting options for the instance.
`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if instances := viper.Get("instances"); instances != nil {
return viper.UnmarshalKey("instances", &c.instances)
}
return nil
},
}
cmd.AddCommand(c.newAddCommand())
cmd.AddCommand(c.newRemoveCommand())
cmd.AddCommand(c.newListCommand())
cmd.AddCommand(cliinstancesset.New(c.runtime).NewCommand())
return cmd
}
-204
View File
@@ -1,204 +0,0 @@
package cliinstances
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/cmd/emoji"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// wrappedInstance implements the FilterableItem interface
type wrappedInstance struct {
instance coolTypes.Instance
}
func (w wrappedInstance) GetFilterValue() string {
return w.instance.Name
}
type filterableListModel struct {
filterableTable *tui.FilterableTable
}
func (c *cliInstances) handleDelete(item tui.FilterableItem) error {
instance := item.(wrappedInstance).instance
// Don't allow deleting default instance without force flag
if instance.Default {
return fmt.Errorf("cannot delete default instance. Use 'instances remove %s --force' instead", instance.Name)
}
// Find and remove the instance from the slice
for i, existing := range c.instances {
if existing.Name == instance.Name {
c.instances = append(c.instances[:i], c.instances[i+1:]...)
break
}
}
// Update viper and save
viper.Set("instances", c.instances)
return c.coolify().Save()
}
func newFilterableListModel(instances []coolTypes.Instance, sensitive bool, initialFilter string, deleteHandler func(tui.FilterableItem) error) *filterableListModel {
columns := []table.Column{
{Title: "Name", Width: 30},
{Title: "URL", Width: 40},
{Title: "Default", Width: 8},
}
// Convert instances to FilterableItems
items := make([]tui.FilterableItem, len(instances))
for i, instance := range instances {
items[i] = wrappedInstance{instance: instance}
}
// Create row builder function
rowBuilder := func(item tui.FilterableItem) table.Row {
instance := item.(wrappedInstance).instance
e := emoji.CrossMark
if instance.Default {
e = emoji.CheckMarkButton
}
return table.Row{
instance.Name,
instance.Fqdn,
e,
}
}
// Create detail view builder function
detailBuilder := func(item tui.FilterableItem, sensitive bool) string {
instance := item.(wrappedInstance).instance
var s strings.Builder
addSection := func(title, value string) {
s.WriteString(tui.FocusedStyle.Bold(true).Render(title + ": "))
s.WriteString(value + "\n\n")
}
addSection("Name", instance.Name)
addSection("URL", instance.Fqdn)
if sensitive {
addSection("Token", instance.Token)
} else {
addSection("Token", "********")
}
addSection("Default", fmt.Sprintf("%v", instance.Default))
return s.String()
}
ft := tui.NewTableFilter(items, columns, rowBuilder).
WithInitialFilter(initialFilter).
WithDetailView(detailBuilder).
WithDetailHeader("Instance Details").
WithDeleteHandler(deleteHandler)
return &filterableListModel{
filterableTable: ft,
}
}
func (m *filterableListModel) Init() tea.Cmd {
return nil
}
func (m *filterableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.filterableTable.Update(msg)
}
func (m *filterableListModel) View() string {
return m.filterableTable.View()
}
func (c *cliInstances) newListCommand() *cobra.Command {
sensitive := false
cmd := &cobra.Command{
Use: "list [name]",
Short: "List all instances",
Long: `
List all instances from the CLI configuration file.
If a name is provided, only instances matching that name will be shown.
`,
SilenceUsage: true,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
initialFilter := ""
if len(args) > 0 {
initialFilter = args[0]
}
format, err := cmd.Flags().GetString("format")
if err != nil {
return fmt.Errorf("failed to get format: %v", err)
}
// If format is json, output JSON and exit
if format == "json" {
// Filter instances for JSON output
filteredInstances := filterInstances(c.instances, initialFilter)
// If not sensitive, redact tokens
if !sensitive {
filteredInstances = redactTokens(filteredInstances)
}
// Encode directly to JSON using the struct's annotations
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(filteredInstances)
}
// Run interactive UI
p := tea.NewProgram(newFilterableListModel(c.instances, sensitive, initialFilter, c.handleDelete))
_, err = p.Run()
if err != nil {
return fmt.Errorf("program error: %v", err)
}
return nil
},
}
flags := cmd.Flags()
flags.BoolVarP(&sensitive, "sensitive", "s", false, "Show sensitive information such as tokens")
return cmd
}
// filterInstances filters instances based on a name filter
func filterInstances(instances []coolTypes.Instance, filter string) []coolTypes.Instance {
if filter == "" {
return instances
}
filtered := make([]coolTypes.Instance, 0)
for _, instance := range instances {
if strings.Contains(strings.ToLower(instance.Name), strings.ToLower(filter)) {
filtered = append(filtered, instance)
}
}
return filtered
}
// redactTokens creates a copy of instances with redacted tokens
func redactTokens(instances []coolTypes.Instance) []coolTypes.Instance {
redacted := make([]coolTypes.Instance, len(instances))
for i, instance := range instances {
// Create a copy to avoid modifying original
redacted[i] = instance
if instance.Token != "" {
redacted[i].Token = "********"
}
}
return redacted
}
-51
View File
@@ -1,51 +0,0 @@
package cliinstances
import (
"errors"
"slices"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func (c *cliInstances) newRemoveCommand() *cobra.Command {
force := false
indexToRemove := -1
cmd := &cobra.Command{
Use: "remove [name]",
Example: utils.GetCommandExample(`
%[1]s instances remove MyInstance
%[1]s instances remove localhost --force
`),
Short: "remove a instance",
Long: `
remove a instance from CLI configuration file.
`,
Aliases: []string{"delete"},
SilenceUsage: true,
Args: cobra.ExactArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
for i, instance := range c.instances {
if instance.Name == args[0] {
if !force && instance.Default {
return errors.New("instance is set as default. Please set another instance as default before removing this instance or provide the force flag")
}
indexToRemove = i
return nil
}
}
return errors.New("instance name is not found in the configuration file")
},
RunE: func(cmd *cobra.Command, args []string) error {
c.instances = slices.Delete(c.instances, indexToRemove, indexToRemove+1)
viper.Set("instances", c.instances)
return c.coolify().Save()
},
}
flags := cmd.Flags()
flags.BoolVarP(&force, "force", "f", false, "Force remove instance if set as default")
return cmd
}
-26
View File
@@ -1,26 +0,0 @@
package cliinstancesset
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func (c *cliInstancesSet) newSetDefaultCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "default [name]",
Short: "set a instance as default",
Long: `
set a instance as default from CLI configuration file.
`,
SilenceUsage: true,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
for i := range c.instances {
c.instances[i].Default = c.instances[i].Name == args[0]
}
viper.Set("instances", c.instances)
},
}
return cmd
}
-59
View File
@@ -1,59 +0,0 @@
package cliinstancesset
import (
"errors"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type cliInstancesSet struct {
coolify runtime.Getter
instances []coolTypes.Instance
}
func New(c runtime.Getter) *cliInstancesSet {
return &cliInstancesSet{
coolify: c,
}
}
// Set command modifies property on a instance. Pre and Post run functions validate all children commands and save the configuration file after the child commands sets a property.
// TLDR; children commands dont need to save the configuration file or do any validation "if instances exists".
func (c *cliInstancesSet) NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "set [command] [args]",
Short: "set a property on a instance",
Long: `
set a property on a instance from CLI configuration file.
`,
SilenceUsage: true,
Args: cobra.ExactArgs(1),
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if instances := viper.Get("instances"); instances != nil {
err := viper.UnmarshalKey("instances", &c.instances)
if err != nil {
return err
}
}
// Validate all set commands have instance name as the first argument and is found in the configuration file.
for _, instance := range c.instances {
if instance.Name == args[0] {
return nil
}
}
return errors.New("instance name is not found in the configuration file")
},
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
// Save the configuration file after setting the property.
return c.coolify().Save()
},
}
cmd.AddCommand(c.newSetDefaultCommand())
cmd.AddCommand(c.newSetTokenCommand())
return cmd
}
-29
View File
@@ -1,29 +0,0 @@
package cliinstancesset
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func (c *cliInstancesSet) newSetTokenCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "token [name] [token]",
Short: "set a instance token",
Long: `
set a instance token from CLI configuration file.
`,
SilenceUsage: true,
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
for i := range c.instances {
if c.instances[i].Name == args[0] {
c.instances[i].Token = args[1]
break
}
}
viper.Set("instances", c.instances)
},
}
return cmd
}
-439
View File
@@ -1,439 +0,0 @@
package cliprivatekeys
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
)
// addKeyMap defines keybindings for the add private key form
type addKeyMap struct {
Up key.Binding
Down key.Binding
Tab key.Binding
Enter key.Binding
Help key.Binding
Quit key.Binding
}
// ShortHelp returns keybindings to be shown in the mini help view
func (k addKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Quit}
}
// FullHelp returns keybindings for the expanded help view
func (k addKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Tab}, // first column
{k.Enter, k.Help}, // second column
{k.Quit}, // third column
}
}
var addKeys = addKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "move up"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "move down"),
),
Tab: key.NewBinding(
key.WithKeys("tab", "shift+tab"),
key.WithHelp("tab", "next field"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "submit/select"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys("esc", "ctrl+c"),
key.WithHelp("esc", "quit"),
),
}
// addKeyModel is the Bubble Tea model for the interactive add key form
type addKeyModel struct {
nameInput textinput.Model
keyInput textinput.Model
focusIndex int
done bool
err error
coolify *runtime.Coolify
keys addKeyMap
help help.Model
}
func initialAddKeyModel(coolify *runtime.Coolify) addKeyModel {
m := addKeyModel{
coolify: coolify,
keys: addKeys,
help: help.New(),
}
// Setup name input
m.nameInput = tui.NewFocusedInput("My SSH Key", " ")
m.nameInput.CharLimit = 50
m.nameInput.Width = 40
// Setup key input (multi-line)
m.keyInput = tui.NewBlurredInput("SSH private key or path to key file", " ")
m.keyInput.CharLimit = 4096
m.keyInput.Width = 60
return m
}
func (m addKeyModel) Init() tea.Cmd {
return textinput.Blink
}
func (m addKeyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg, ok := msg.(tea.KeyMsg); ok {
if key.Matches(msg, m.keys.Quit) {
return m, tea.Quit
}
if key.Matches(msg, m.keys.Help) {
m.help.ShowAll = !m.help.ShowAll
return m, nil
}
if key.Matches(msg, m.keys.Enter) {
// Submit on enter when key input is focused
if m.focusIndex == 1 {
m.done = true
return m, tea.Quit
}
// Otherwise move to next input
m.focusIndex++
if m.focusIndex > 1 {
m.focusIndex = 0
}
return m, m.updateFocus()
}
if key.Matches(msg, m.keys.Tab) {
// Cycle focus between inputs
if msg.String() == "shift+tab" {
m.focusIndex--
} else {
m.focusIndex++
}
if m.focusIndex > 1 {
m.focusIndex = 0
} else if m.focusIndex < 0 {
m.focusIndex = 1
}
return m, m.updateFocus()
}
if key.Matches(msg, m.keys.Up) {
m.focusIndex--
if m.focusIndex < 0 {
m.focusIndex = 1
}
return m, m.updateFocus()
}
if key.Matches(msg, m.keys.Down) {
m.focusIndex++
if m.focusIndex > 1 {
m.focusIndex = 0
}
return m, m.updateFocus()
}
}
// Handle character input for the active input
if m.focusIndex == 0 {
var cmd tea.Cmd
m.nameInput, cmd = m.nameInput.Update(msg)
return m, cmd
} else {
var cmd tea.Cmd
m.keyInput, cmd = m.keyInput.Update(msg)
return m, cmd
}
}
func (m addKeyModel) updateFocus() tea.Cmd {
var cmds []tea.Cmd
if m.focusIndex == 0 {
m.nameInput.PromptStyle = tui.FocusedStyle
m.nameInput.TextStyle = tui.FocusedStyle
m.keyInput.PromptStyle = tui.BlurredStyle
m.keyInput.TextStyle = tui.BlurredStyle
cmds = append(cmds, m.nameInput.Focus())
m.keyInput.Blur()
} else {
m.keyInput.PromptStyle = tui.FocusedStyle
m.keyInput.TextStyle = tui.FocusedStyle
m.nameInput.PromptStyle = tui.BlurredStyle
m.nameInput.TextStyle = tui.BlurredStyle
cmds = append(cmds, m.keyInput.Focus())
m.nameInput.Blur()
}
return tea.Batch(cmds...)
}
func (m addKeyModel) View() string {
var b strings.Builder
// Title with Coolify branding
title := tui.FocusedStyle.Bold(true).Render("Add New SSH Private Key")
b.WriteString(title + "\n\n")
// Render inputs with labels
labelStyle := tui.BlurredStyle.Width(12)
b.WriteString(labelStyle.Render("Name:") + " " + m.nameInput.View() + "\n\n")
b.WriteString(labelStyle.Render("Private Key:") + " " + m.keyInput.View() + "\n\n")
// Add help view
if m.help.ShowAll {
b.WriteString("\n\n")
b.WriteString(m.help.View(m.keys))
} else {
b.WriteString("\n\n")
b.WriteString(m.help.ShortHelpView(m.keys.ShortHelp()))
}
return b.String()
}
func generateRSAKeyPair() (privateBytes, publicBytes []byte, err error) {
// Generate RSA key pair
privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate RSA key pair: %w", err)
}
// Convert private key to PEM format
privateKeyPEM := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
}
privateBytes = pem.EncodeToMemory(privateKeyPEM)
// Generate public key
publicKey, err := ssh.NewPublicKey(&privateKey.PublicKey)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate public key: %w", err)
}
publicBytes = ssh.MarshalAuthorizedKey(publicKey)
return privateBytes, publicBytes, nil
}
func generateEd25519KeyPair() (privateBytes, publicBytes []byte, err error) {
// Generate Ed25519 key pair
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate Ed25519 key pair: %w", err)
}
privateKeyPem, err := ssh.MarshalPrivateKey(privateKey, "")
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal private key: %w", err)
}
privateBytes = pem.EncodeToMemory(privateKeyPem)
// Generate public key
sshPublicKey, err := ssh.NewPublicKey(publicKey)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate public key: %w", err)
}
publicBytes = ssh.MarshalAuthorizedKey(sshPublicKey)
return privateBytes, publicBytes, nil
}
func (c *cliPrivateKeys) generateKeyPair(name, outputDir, alorithim string, force bool) (string, error) {
var privateKey, publicKey []byte
var err error
switch alorithim {
case "rsa":
privateKey, publicKey, err = generateRSAKeyPair()
case "ed25519":
privateKey, publicKey, err = generateEd25519KeyPair()
default:
return "", fmt.Errorf("invalid alorithim: %s", alorithim)
}
if err != nil {
return "", err
}
if outputDir != "" {
if err := os.MkdirAll(outputDir, 0o700); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
// Write private key file
privateKeyPath := filepath.Join(outputDir, name)
if !force {
if _, err := os.Stat(privateKeyPath); err == nil {
return "", fmt.Errorf("private key file already exists: %s", privateKeyPath)
}
}
if err := os.WriteFile(privateKeyPath, privateKey, 0o600); err != nil {
return "", fmt.Errorf("failed to write private key file: %w", err)
}
// Write public key file
publicKeyPath := privateKeyPath + ".pub"
if err := os.WriteFile(publicKeyPath, publicKey, 0o644); err != nil {
return "", fmt.Errorf("failed to write public key file: %w", err)
}
fmt.Printf("Generated SSH key pair:\n")
fmt.Printf(" Private key: %s\n", privateKeyPath)
fmt.Printf(" Public key: %s\n", publicKeyPath)
}
return string(privateKey), nil
}
func (c *cliPrivateKeys) newAddCommand() *cobra.Command {
var generateKeyPair bool
var outPutDirectory string
var algorithm string
var force bool
cmd := &cobra.Command{
Use: "add [name] [private_key_or_file]",
Short: "Add a new private key",
Long: `Add a new SSH private key to your Coolify instance.
The key can be provided directly as a string or as a path to a file.
Use --generate to create a new SSH key pair.
If no arguments are provided, an interactive form will be used.`,
Example: utils.GetCommandExample(`
%[1]s private-keys add "My Key" /path/to/id_rsa
%[1]s private-keys add "My Key" "-----BEGIN RSA PRIVATE KEY-----..."
%[1]s private-keys add "My Key" --generate # Generate key pair
%[1]s private-keys add # Interactive mode
`),
SilenceUsage: true,
Args: func(cmd *cobra.Command, args []string) error {
if generateKeyPair {
if len(args) != 1 {
return fmt.Errorf("when using --generate, provide only the key name")
}
return nil
}
return cobra.RangeArgs(0, 2)(cmd, args)
},
RunE: func(cmd *cobra.Command, args []string) error {
// Handle key generation
if generateKeyPair {
name := args[0]
privateKey, err := c.generateKeyPair(name, outPutDirectory, algorithm, force)
if err != nil {
return err
}
return c.addPrivateKey(cmd.Context(), name, privateKey)
}
// Interactive mode when no arguments are provided
if len(args) == 0 {
model := initialAddKeyModel(c.coolify())
p := tea.NewProgram(model)
finalModel, err := p.Run()
if err != nil {
return fmt.Errorf("error running interactive mode: %w", err)
}
// Process the final model after user submission
finalState := finalModel.(addKeyModel)
if !finalState.done {
return fmt.Errorf("operation canceled")
}
name := finalState.nameInput.Value()
privateKeyInput := finalState.keyInput.Value()
return c.addPrivateKey(cmd.Context(), name, privateKeyInput)
}
// CLI mode with arguments
if len(args) != 2 {
return fmt.Errorf("requires both NAME and PRIVATE_KEY_OR_FILE arguments")
}
name := args[0]
privateKeyInput := args[1]
return c.addPrivateKey(cmd.Context(), name, privateKeyInput)
},
}
flags := cmd.Flags()
flags.SortFlags = false
flags.BoolVarP(&generateKeyPair, "generate", "g", false, "generate a new key pair")
flags.StringVarP(&algorithm, "algorithm", "a", "rsa", "algorithm to use for the key pair")
flags.StringVarP(&outPutDirectory, "output", "o", "", "optional output directory for the key pair")
flags.BoolVarP(&force, "force", "f", false, "force the generation of the key pair if the name exists on the file system within the output directory")
return cmd
}
// addPrivateKey adds a private key to the Coolify instance
func (c *cliPrivateKeys) addPrivateKey(ctx context.Context, name, privateKeyInput string) error {
// Check if input is a file path
var privateKey string
if _, err := os.Stat(privateKeyInput); err == nil {
keyBytes, err := os.ReadFile(privateKeyInput)
if err != nil {
return fmt.Errorf("error reading private key file: %w", err)
}
privateKey = string(keyBytes)
} else {
privateKey = privateKeyInput
}
req, err := c.coolify().Client.CreatePrivateKey(ctx, openapi.CreatePrivateKeyJSONRequestBody{
Name: &name,
PrivateKey: privateKey,
})
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
parsedResponse, err := openapi.ParseCreatePrivateKeyResponse(req)
if err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if parsedResponse.StatusCode() != http.StatusCreated {
return fmt.Errorf("failed to add private key: %s", string(parsedResponse.Body))
}
fmt.Printf("Private key '%s' added successfully as UUID: %s\n", name, *parsedResponse.JSON201.Uuid)
return nil
}
-266
View File
@@ -1,266 +0,0 @@
package cliprivatekeys
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
func buildView(item openapi.PrivateKey, sensitive bool) string {
var s strings.Builder
addSection := func(title string, value interface{}) {
s.WriteString(tui.FocusedStyle.Bold(true).Render(title + ": "))
if value != nil {
switch v := value.(type) {
case *string:
if v != nil {
s.WriteString(*v + "\n\n")
}
case *bool:
if v != nil {
s.WriteString(fmt.Sprintf("%v\n\n", *v))
}
case *int:
if v != nil {
s.WriteString(fmt.Sprintf("%d\n\n", *v))
}
}
} else {
s.WriteString("N/A\n\n")
}
}
addSection("UUID", item.Uuid)
addSection("Name", item.Name)
addSection("Description", item.Description)
addSection("Fingerprint", item.Fingerprint)
if sensitive {
addSection("Private Key", item.PrivateKey)
addSection("Public Key", item.PublicKey)
} else {
addSection("Private Key", &coolTypes.Redacted)
addSection("Public Key", &coolTypes.Redacted)
}
addSection("Git Related", item.IsGitRelated)
addSection("Team ID", item.TeamId)
addSection("Created At", item.CreatedAt)
addSection("Updated At", item.UpdatedAt)
return s.String()
}
type keyMap struct {
Up key.Binding
Down key.Binding
PageUp key.Binding
PageDown key.Binding
Quit key.Binding
ShowSensitive key.Binding
}
func defaultKeyMap() keyMap {
return keyMap{
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "move up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "move down"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup"),
key.WithHelp("pgup", "page up"),
),
PageDown: key.NewBinding(
key.WithKeys("pgdown"),
key.WithHelp("pgdown", "page down"),
),
Quit: key.NewBinding(
key.WithKeys("esc", "ctrl+c"),
key.WithHelp("esc", "quit"),
),
ShowSensitive: key.NewBinding(
key.WithKeys("ctrl+s"),
key.WithHelp("ctrl+s", "show sensitive"),
),
}
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Up, k.Down, k.Quit}
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down},
{k.PageUp, k.PageDown},
{k.Quit},
{k.ShowSensitive},
}
}
type privateKeyModel struct {
viewport viewport.Model
keymap keyMap
help help.Model
ready bool
privateKey openapi.PrivateKey
sensitive bool
quitting bool
err error
}
func newPrivateKeyModel(privateKey openapi.PrivateKey, sensitive bool) privateKeyModel {
return privateKeyModel{
keymap: defaultKeyMap(),
help: help.New(),
privateKey: privateKey,
sensitive: sensitive,
}
}
func (m privateKeyModel) Init() tea.Cmd {
return nil
}
func (m privateKeyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var (
cmd tea.Cmd
cmds []tea.Cmd
)
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keymap.Quit):
m.quitting = true
return m, tea.Quit
case key.Matches(msg, m.keymap.Up):
m.viewport.LineUp(1)
case key.Matches(msg, m.keymap.Down):
m.viewport.LineDown(1)
case key.Matches(msg, m.keymap.PageUp):
m.viewport.HalfViewUp()
case key.Matches(msg, m.keymap.PageDown):
m.viewport.HalfViewDown()
case key.Matches(msg, m.keymap.ShowSensitive):
m.sensitive = !m.sensitive
m.viewport.SetContent(buildView(m.privateKey, m.sensitive))
}
case tea.WindowSizeMsg:
if !m.ready {
m.viewport = viewport.New(msg.Width, msg.Height-4)
m.viewport.Style = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Padding(0, 2)
m.viewport.SetContent(buildView(m.privateKey, m.sensitive))
m.help.Width = msg.Width
m.ready = true
} else {
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - 4
m.help.Width = msg.Width
}
}
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m privateKeyModel) View() string {
if !m.ready {
return "Initializing..."
}
if m.err != nil {
return fmt.Sprintf("Error: %v\nPress esc to quit", m.err)
}
var s strings.Builder
s.WriteString(m.viewport.View())
s.WriteString("\n\n")
s.WriteString(m.help.View(m.keymap))
return s.String()
}
func (c *cliPrivateKeys) newGetCommand() *cobra.Command {
var showSensitive bool
cmd := &cobra.Command{
Use: "get [uuid]",
Short: "Get private key details",
Long: `Get the details of a specific private key by its UUID.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uuid := args[0]
response, err := c.coolify().Client.GetPrivateKeyByUuid(cmd.Context(), uuid)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
parsedResponse, err := openapi.ParseGetPrivateKeyByUuidResponse(response)
if err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if parsedResponse.StatusCode() != http.StatusOK {
return fmt.Errorf("failed to fetch private key: %s", string(parsedResponse.Body))
}
key := *parsedResponse.JSON200
format, err := cmd.Flags().GetString("format")
if err != nil {
return fmt.Errorf("failed to get format: %w", err)
}
if format == "json" {
// Redact sensitive data if --show-sensitive is not set
if !showSensitive {
// Create a copy with redacted sensitive fields
redactedKey := key
redactedKey.PrivateKey = &coolTypes.Redacted
redactedKey.PublicKey = &coolTypes.Redacted
key = redactedKey
}
// For JSON output, directly encode to stdout
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(key)
}
// Initialize and run Bubble Tea program
m := newPrivateKeyModel(key, showSensitive)
p := tea.NewProgram(m)
if _, err := p.Run(); err != nil {
return fmt.Errorf("error running program: %w", err)
}
return nil
},
}
// Add flags
flags := cmd.Flags()
flags.BoolVarP(&showSensitive, "show-sensitive", "s", false, "Show sensitive information like key contents")
return cmd
}
-203
View File
@@ -1,203 +0,0 @@
package cliprivatekeys
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
type filterableListModel struct {
FilterableTable *tui.FilterableTable
}
func newFilterableListModel(keys []openapi.PrivateKey, filter string) *filterableListModel {
columns := []table.Column{
{Title: "UUID", Width: 30},
{Title: "Name", Width: 30},
{Title: "Created At", Width: 30},
}
return &filterableListModel{
FilterableTable: tui.NewTableFilter(wrapKeys(keys), columns, buildRow).
WithInitialFilter(filter).
WithDetailView(buildDetailView).
WithDetailHeader("Private Key Details"),
}
}
func wrapKeys(keys []openapi.PrivateKey) []tui.FilterableItem {
items := make([]tui.FilterableItem, len(keys))
for i, key := range keys {
items[i] = &key
}
return items
}
func buildRow(item tui.FilterableItem) table.Row {
key := item.(*openapi.PrivateKey)
return table.Row{
*key.Uuid,
*key.Name,
*key.CreatedAt,
}
}
func buildDetailView(item tui.FilterableItem, sensitive bool) string {
key := item.(*openapi.PrivateKey)
var s strings.Builder
addSection := func(title string, value interface{}) {
s.WriteString(tui.FocusedStyle.Bold(true).Render(title + ": "))
if value != nil {
switch v := value.(type) {
case *string:
if v != nil {
s.WriteString(*v + "\n\n")
}
case *bool:
if v != nil {
s.WriteString(fmt.Sprintf("%v\n\n", *v))
}
case *int:
if v != nil {
s.WriteString(fmt.Sprintf("%d\n\n", *v))
}
}
} else {
s.WriteString("N/A\n\n")
}
}
addSection("UUID", key.Uuid)
addSection("Name", key.Name)
addSection("Description", key.Description)
addSection("Fingerprint", key.Fingerprint)
if sensitive {
addSection("Private Key", key.PrivateKey)
addSection("Public Key", key.PublicKey)
} else {
addSection("Private Key", &coolTypes.Redacted)
addSection("Public Key", &coolTypes.Redacted)
}
addSection("Git Related", key.IsGitRelated)
addSection("Team ID", key.TeamId)
addSection("Created At", key.CreatedAt)
addSection("Updated At", key.UpdatedAt)
return s.String()
}
func (m *filterableListModel) Init() tea.Cmd {
return nil
}
func (m *filterableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.FilterableTable.Update(msg)
}
func (m *filterableListModel) View() string {
return m.FilterableTable.View()
}
func (c *cliPrivateKeys) handleDelete(item tui.FilterableItem) error {
key := item.(*openapi.PrivateKey)
deleteReq, err := c.coolify().Client.DeletePrivateKeyByUuid(context.Background(), *key.Uuid)
if err != nil {
return fmt.Errorf("failed to create delete request: %w", err)
}
parsedResponse, err := openapi.ParseDeletePrivateKeyByUuidResponse(deleteReq)
if err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
switch parsedResponse.StatusCode() {
case http.StatusUnprocessableEntity:
return fmt.Errorf("failed to delete private key: %s", *parsedResponse.JSON422.Message)
case http.StatusOK:
return nil
default:
return fmt.Errorf("failed to delete private key: %s", string(parsedResponse.Body))
}
}
func (c *cliPrivateKeys) newListCommand() *cobra.Command {
var filter string
var showSensitive bool
cmd := &cobra.Command{
Use: "list [filter]",
Short: "List all private keys",
Long: `List all SSH private keys registered in your Coolify instance.`,
Example: utils.GetCommandExample(`
%[1]s private-keys list --format json
%[1]s private-keys list "My Key"
%[1]s private-keys list --show-sensitive
%[1]s private-keys list # Interactive mode
`),
SilenceUsage: true,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
filter = args[0]
}
response, err := c.coolify().Client.ListPrivateKeys(cmd.Context())
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
parsedResponse, err := openapi.ParseListPrivateKeysResponse(response)
if err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if parsedResponse.StatusCode() != http.StatusOK {
return fmt.Errorf("failed to fetch private keys: %s", string(parsedResponse.Body))
}
keys := *parsedResponse.JSON200
format, _ := cmd.Flags().GetString("format")
if format == "json" {
// For JSON output, redact sensitive data if --show-sensitive is not set
if !showSensitive {
// Create a copy with redacted sensitive fields
redactedKeys := make([]openapi.PrivateKey, len(*parsedResponse.JSON200))
for i, key := range *parsedResponse.JSON200 {
redactedKeys[i] = key
redactedKeys[i].PrivateKey = &coolTypes.Redacted
redactedKeys[i].PublicKey = &coolTypes.Redacted
}
keys = redactedKeys
}
// For JSON output, directly encode to stdout
encoder := json.NewEncoder(cmd.OutOrStdout())
encoder.SetIndent("", " ")
return encoder.Encode(keys)
}
model := newFilterableListModel(keys, filter)
model.FilterableTable.WithDeleteHandler(c.handleDelete)
p := tea.NewProgram(model)
_, err = p.Run()
return err
},
}
cmd.Flags().BoolVarP(&showSensitive, "show-sensitive", "s", false, "Show sensitive information like public keys")
return cmd
}
-32
View File
@@ -1,32 +0,0 @@
package cliprivatekeys
import (
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/spf13/cobra"
)
type cliPrivateKeys struct {
coolify runtime.Getter
}
func New(c runtime.Getter) *cliPrivateKeys {
return &cliPrivateKeys{
coolify: c,
}
}
func (c *cliPrivateKeys) NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "private-keys",
Short: "Manage SSH private keys",
Long: `Manage SSH private keys for your Coolify instance.`,
}
// Add subcommands
cmd.AddCommand(c.newListCommand())
cmd.AddCommand(c.newGetCommand())
cmd.AddCommand(c.newAddCommand())
cmd.AddCommand(c.newRemoveCommand())
return cmd
}
-70
View File
@@ -1,70 +0,0 @@
package cliprivatekeys
import (
"fmt"
"net/http"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
func (c *cliPrivateKeys) newRemoveCommand() *cobra.Command {
var forceRemove bool
cmd := &cobra.Command{
Use: "remove [uuid]",
Short: "Remove a private key",
Long: `Remove an private key from your Coolify instance.`,
SilenceUsage: true,
Aliases: []string{"delete", "rm"},
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uuid := args[0]
if !forceRemove {
fmt.Printf("Are you sure you want to remove the private key with UUID '%s'? [y/N] ", uuid)
var confirm string
_, err := fmt.Scanln(&confirm)
if err != nil {
return fmt.Errorf("failed to read confirmation: %w", err)
}
if confirm != "y" && confirm != "Y" {
fmt.Println("Operation canceled")
return nil
}
}
req, err := c.coolify().Client.DeletePrivateKeyByUuid(cmd.Context(), uuid)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
parsedResponse, err := openapi.ParseDeletePrivateKeyByUuidResponse(req)
if err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if parsedResponse.StatusCode() != http.StatusOK {
errorMessage := "failed to remove private key"
switch parsedResponse.StatusCode() {
case http.StatusBadRequest:
errorMessage = fmt.Sprintf("%s: %s", errorMessage, *parsedResponse.JSON400.Message)
case http.StatusUnprocessableEntity:
errorMessage = fmt.Sprintf("%s: %s", errorMessage, *parsedResponse.JSON422.Message)
default:
errorMessage = fmt.Sprintf("%s: %s", errorMessage, string(parsedResponse.Body))
}
return fmt.Errorf("%s", errorMessage)
}
fmt.Println(tui.SuccessStyle.Render("Private key removed successfully"))
return nil
},
}
// Add flags
flags := cmd.Flags()
flags.BoolVarP(&forceRemove, "force", "f", false, "Attempt to remove without confirmation prompt")
return cmd
}
-319
View File
@@ -1,319 +0,0 @@
package cliservers
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
// addKeyMap defines keybindings for the add server form
type addKeyMap struct {
Up key.Binding
Down key.Binding
Tab key.Binding
Enter key.Binding
Help key.Binding
Quit key.Binding
}
// ShortHelp returns keybindings to be shown in the mini help view
func (k addKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Quit}
}
// FullHelp returns keybindings for the expanded help view
func (k addKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Tab}, // first column
{k.Enter, k.Help}, // second column
{k.Quit}, // third column
}
}
var addKeys = addKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "move up"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "move down"),
),
Tab: key.NewBinding(
key.WithKeys("tab", "shift+tab"),
key.WithHelp("tab", "next field"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "submit/select"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys("esc", "ctrl+c"),
key.WithHelp("esc", "quit"),
),
}
type addModel struct {
inputs []textinput.Model
focusIndex int
err error
done bool
keys addKeyMap
help help.Model
}
func (c *cliServers) newAddCommand() *cobra.Command {
var validate bool
cmd := &cobra.Command{
Use: "add [name] [ip] [private_key_uuid]",
Short: "Add a new server",
Long: `
Add a new server to your Coolify instance.
If no arguments are provided, an interactive form will be shown.`,
SilenceUsage: true,
Example: utils.GetCommandExample(`
%[1]s servers add "My Server" 192.168.1.100 abcd1234-uuid
%[1]s servers add "Production" 10.0.0.1 efgh5678-uuid --validate
%[1]s servers add # Interactive mode`),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return c.runInteractiveAdd(validate)
}
if len(args) != 3 {
return fmt.Errorf("requires exactly 3 arguments (name, ip, private_key_uuid) or no arguments for interactive mode")
}
return c.addServer(args[0], args[1], args[2], 22, "root", validate)
},
}
cmd.Flags().BoolVar(&validate, "validate", false, "Validate the server after adding")
return cmd
}
func (c *cliServers) runInteractiveAdd(validate bool) error {
p := tea.NewProgram(initialAddModel())
m, err := p.Run()
if err != nil {
return fmt.Errorf("error running form: %w", err)
}
finalModel := m.(addModel)
if !finalModel.done {
return fmt.Errorf("operation cancelled")
}
// Get values from the form
name := strings.TrimSpace(finalModel.inputs[0].Value())
ip := strings.TrimSpace(finalModel.inputs[1].Value())
port := strings.TrimSpace(finalModel.inputs[2].Value())
user := strings.TrimSpace(finalModel.inputs[3].Value())
privateKeyUUID := strings.TrimSpace(finalModel.inputs[4].Value())
// Convert port to int with default 22
portNum := 22
if port != "" {
if _, err := fmt.Sscanf(port, "%d", &portNum); err != nil {
return fmt.Errorf("invalid port number: %s", port)
}
}
// Use default user if not specified
if user == "" {
user = "root"
}
return c.addServer(name, ip, privateKeyUUID, portNum, user, validate)
}
func initialAddModel() addModel {
inputs := make([]textinput.Model, 5)
// Initialize text inputs
labels := []string{"Name", "IP Address", "Port (default: 22)", "User (default: root)", "Private Key UUID"}
for i := range inputs {
input := tui.NewBlurredInput(labels[i], "")
inputs[i] = input
}
inputs[0].Focus()
return addModel{
inputs: inputs,
err: nil,
keys: addKeys,
help: help.New(),
}
}
func (m addModel) Init() tea.Cmd {
return textinput.Blink
}
func (m addModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
if msg, ok := msg.(tea.KeyMsg); ok {
if key.Matches(msg, m.keys.Quit) {
m.done = false
return m, tea.Quit
}
if key.Matches(msg, m.keys.Help) {
m.help.ShowAll = !m.help.ShowAll
}
if key.Matches(msg, m.keys.Enter) {
// Submit on enter when last input is focused
if m.focusIndex == len(m.inputs)-1 {
m.done = true
return m, tea.Quit
}
// Otherwise move to next input
m.focusIndex++
if m.focusIndex >= len(m.inputs) {
m.focusIndex = 0
}
m.updateFocus()
}
if key.Matches(msg, m.keys.Tab) {
// Cycle focus between inputs
if msg.String() == "shift+tab" {
m.focusIndex--
} else {
m.focusIndex++
}
if m.focusIndex >= len(m.inputs) {
m.focusIndex = 0
} else if m.focusIndex < 0 {
m.focusIndex = len(m.inputs) - 1
}
m.updateFocus()
}
if key.Matches(msg, m.keys.Up) {
m.focusIndex--
if m.focusIndex < 0 {
m.focusIndex = len(m.inputs) - 1
}
m.updateFocus()
}
if key.Matches(msg, m.keys.Down) {
m.focusIndex++
if m.focusIndex >= len(m.inputs) {
m.focusIndex = 0
}
m.updateFocus()
}
}
// Handle character input
cmd := m.updateInputs(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *addModel) updateFocus() {
for i := 0; i < len(m.inputs); i++ {
if i == m.focusIndex {
m.inputs[i].Focus()
} else {
m.inputs[i].Blur()
}
}
}
func (m *addModel) updateInputs(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
m.inputs[m.focusIndex], cmd = m.inputs[m.focusIndex].Update(msg)
return cmd
}
func (m addModel) View() string {
var b strings.Builder
b.WriteString("Please enter server details:\n\n")
for i, input := range m.inputs {
b.WriteString(input.View())
if i < len(m.inputs)-1 {
b.WriteString("\n")
}
}
button := "\n\n"
if m.focusIndex == len(m.inputs)-1 {
button += lipgloss.NewStyle().
Foreground(lipgloss.Color("99")).
Render("[ Submit ]")
} else {
button += "[ Submit ]"
}
b.WriteString(button)
// Add help view
if m.help.ShowAll {
b.WriteString("\n\n")
b.WriteString(m.help.View(m.keys))
} else {
b.WriteString("\n\n")
b.WriteString(m.help.ShortHelpView(m.keys.ShortHelp()))
}
return b.String()
}
func (c *cliServers) addServer(name, ip, privateKeyUUID string, port int, user string, validate bool) error {
req, err := c.coolify().Client.CreateServer(context.Background(), openapi.CreateServerJSONRequestBody{
Name: &name,
Ip: &ip,
Port: &port,
User: &user,
PrivateKeyUuid: &privateKeyUUID,
InstantValidate: &validate,
})
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
parsedResponse, err := openapi.ParseCreateServerResponse(req)
if err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if parsedResponse.StatusCode() != http.StatusCreated {
return fmt.Errorf("failed to add server: %s", *parsedResponse.JSON400.Message)
}
if validate {
fmt.Printf("Server added successfully with uuid %s\n", *parsedResponse.JSON201.Uuid)
} else {
fmt.Printf("Server added successfully with uuid %s. Server is not validated. Use 'servers validate %s' to validate the server.\n", *parsedResponse.JSON201.Uuid, *parsedResponse.JSON201.Uuid)
}
return nil
}
-163
View File
@@ -1,163 +0,0 @@
package cliservers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
type getModel struct {
server *openapi.Server
sensitive bool
withResources bool
err error
}
func (c *cliServers) newGetCommand() *cobra.Command {
var withResources bool
cmd := &cobra.Command{
Use: "get [uuid]",
Short: "Get server details",
Long: `
Get detailed information about a specific server.
Optionally show its resources and sensitive information.`,
Example: utils.GetCommandExample(`
%[1]s servers get 123e4567-e89b-12d3-a456-426614174000
%[1]s servers get 123e4567-e89b-12d3-a456-426614174000 --resources
%[1]s servers get 123e4567-e89b-12d3-a456-426614174000 --sensitive
%[1]s servers get 123e4567-e89b-12d3-a456-426614174000 --format json`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uuid := args[0]
// Fetch server details
serverData, err := c.fetchServer(cmd.Context(), uuid, withResources)
if err != nil {
return fmt.Errorf("failed to fetch server details: %w", err)
}
outFormat, err := cmd.Flags().GetString("format")
if err != nil {
return fmt.Errorf("failed to get output format: %w", err)
}
// Handle JSON output format
if outFormat == "json" {
return json.NewEncoder(os.Stdout).Encode(serverData)
}
// Create and run Bubble Tea program for interactive display
p := tea.NewProgram(initialGetModel(serverData))
if _, err := p.Run(); err != nil {
return fmt.Errorf("error running detail view: %w", err)
}
return nil
},
}
flags := cmd.Flags()
flags.BoolVar(&withResources, "resources", false, "Show server resources")
return cmd
}
func initialGetModel(server *openapi.Server) getModel {
return getModel{
server: server,
}
}
// Implement Bubble Tea Model interface
func (m getModel) Init() tea.Cmd { return nil }
func (m getModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg, ok := msg.(tea.KeyMsg); ok {
if msg.String() == "ctrl+c" || msg.String() == "esc" {
return m, tea.Quit
}
}
return m, nil
}
func (m getModel) View() string {
var s strings.Builder
// Create styles
titleStyle := tui.FocusedStyle.
Bold(true).
MarginBottom(1)
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("60"))
valueStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("99"))
// Server details section
s.WriteString(titleStyle.Render("Server Details"))
s.WriteString("\n")
// Helper function to add a field
addField := func(label, value string) {
s.WriteString(fmt.Sprintf("%s: %s\n",
labelStyle.Render(label),
valueStyle.Render(value)))
}
addField("UUID", *m.server.Uuid)
addField("Name", *m.server.Name)
addField("IP Address", *m.server.Ip)
addField("User", *m.server.User)
addField("Port", fmt.Sprintf("%d", *m.server.Port))
status := "Offline"
if *m.server.Settings.IsReachable && *m.server.Settings.IsUsable {
status = "Online"
}
addField("Status", status)
return "\n" + s.String()
}
func (c *cliServers) fetchServer(ctx context.Context, uuid string, withResources bool) (*openapi.Server, error) {
req, err := c.coolify().Client.GetServerByUuid(ctx, uuid, func(ctx context.Context, req *http.Request) error {
if withResources {
req.URL.RawQuery = url.Values{"resources": {"true"}}.Encode()
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
parsedResponse, err := openapi.ParseGetServerByUuidResponse(req)
if err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if parsedResponse.StatusCode() != http.StatusOK {
switch parsedResponse.StatusCode() {
case http.StatusNotFound:
return nil, fmt.Errorf("failed to get server: %s", *parsedResponse.JSON404.Message)
default:
return nil, fmt.Errorf("failed to get server: %s", string(parsedResponse.Body))
}
}
return parsedResponse.JSON200, nil
}
-216
View File
@@ -1,216 +0,0 @@
package cliservers
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
type listModel struct {
filterableTable *tui.FilterableTable
servers *[]openapi.Server
sensitive bool
filter string
err error
}
func (c *cliServers) newListCommand() *cobra.Command {
var showSensitive bool
var initialFilter string
cmd := &cobra.Command{
Use: "list [filter]",
Short: "List all servers",
Long: `
List all servers registered in your Coolify instance.
Use --sensitive to show sensitive information like IP addresses.`,
Example: utils.GetCommandExample(`
%[1]s servers list
%[1]s servers list "my-server"
%[1]s servers list --format json
%[1]s servers list --sensitive`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
initialFilter = args[0]
}
// Fetch servers from API
data, err := c.fetchServers(cmd.Context())
if err != nil {
return fmt.Errorf("failed to fetch servers: %w", err)
}
outputFormat, err := cmd.Flags().GetString("format")
if err != nil {
return fmt.Errorf("failed to get output format: %w", err)
}
// Handle JSON output format
if outputFormat == "json" {
return json.NewEncoder(os.Stdout).Encode(data)
}
// Create and run Bubble Tea program for interactive display
p := tea.NewProgram(initialListModel(data, showSensitive, initialFilter))
if _, err := p.Run(); err != nil {
return fmt.Errorf("error running list view: %w", err)
}
return nil
},
}
flags := cmd.Flags()
flags.BoolVarP(&showSensitive, "sensitive", "s", false, "Show sensitive information")
return cmd
}
func initialListModel(servers *[]openapi.Server, sensitive bool, initialFilter string) listModel {
columns := []table.Column{
{Title: "UUID", Width: 36},
{Title: "Name", Width: 30},
{Title: "IP Address", Width: 15},
}
// Convert servers to FilterableItems
items := make([]tui.FilterableItem, len(*servers))
for i, s := range *servers {
items[i] = &s
}
// Create row builder function
rowBuilder := func(item tui.FilterableItem) table.Row {
s := item.(*openapi.Server)
return table.Row{
*s.Uuid,
*s.Name,
*s.Ip,
}
}
detailBuilder := func(item tui.FilterableItem, sensitive bool) string {
s := item.(*openapi.Server)
var builder strings.Builder
addSection := func(title, value interface{}) {
builder.WriteString(tui.FocusedStyle.Bold(true).Render(fmt.Sprintf("%s: ", title)))
switch v := value.(type) {
case *string:
builder.WriteString(*v)
case *int:
builder.WriteString(fmt.Sprintf("%d", *v))
case *openapi.ServerProxyType:
if v != nil {
builder.WriteString(string(*v))
} else {
builder.WriteString("N/A")
}
case string:
builder.WriteString(v)
case *bool:
if v != nil {
builder.WriteString(fmt.Sprintf("%t", *v))
} else {
builder.WriteString("N/A")
}
}
builder.WriteString("\n\n")
}
addSection("UUID", s.Uuid)
addSection("Name", s.Name)
addSection("IP Address", s.Ip)
addSection("User", s.User)
addSection("Port", s.Port)
addSection("Proxy Type", s.ProxyType)
addSection("Settings", "")
addSection(" Created At", s.Settings.CreatedAt)
addSection(" Updated At", s.Settings.UpdatedAt)
addSection(" Server ID", s.Settings.ServerId)
addSection(" Concurrent Builds", s.Settings.ConcurrentBuilds)
addSection(" Dynamic Timeout", s.Settings.DynamicTimeout)
addSection(" Docker", "")
addSection(" Delete Unused Networks", s.Settings.DeleteUnusedNetworks)
addSection(" Delete Unused Volumes", s.Settings.DeleteUnusedVolumes)
addSection(" Cleanup Frequency", s.Settings.DockerCleanupFrequency)
addSection(" Cleanup Threshold", s.Settings.DockerCleanupThreshold)
addSection(" Force Disabled", s.Settings.ForceDisabled)
addSection(" Force Server Cleanup", s.Settings.ForceServerCleanup)
addSection(" Is Build Server", s.Settings.IsBuildServer)
addSection(" Is Cloudflare Tunnel", s.Settings.IsCloudflareTunnel)
addSection(" Is Jump Server", s.Settings.IsJumpServer)
if s.Settings.IsLogdrainAxiomEnabled != nil && *s.Settings.IsLogdrainAxiomEnabled {
addSection(" Axiom", "")
addSection(" API Key", s.Settings.LogdrainAxiomApiKey)
addSection(" Dataset Name", s.Settings.LogdrainAxiomDatasetName)
}
if s.Settings.IsLogdrainCustomEnabled != nil && *s.Settings.IsLogdrainCustomEnabled {
addSection(" Custom Drain", "")
addSection(" Config", s.Settings.LogdrainCustomConfig)
addSection(" Config Parser", s.Settings.LogdrainCustomConfigParser)
}
if s.Settings.IsLogdrainHighlightEnabled != nil && *s.Settings.IsLogdrainHighlightEnabled {
addSection(" Highlight", "")
addSection(" Project ID", s.Settings.LogdrainHighlightProjectId)
}
if s.Settings.IsLogdrainNewrelicEnabled != nil && *s.Settings.IsLogdrainNewrelicEnabled {
addSection(" Newrelic", "")
addSection(" Base URI", s.Settings.LogdrainNewrelicBaseUri)
addSection(" License Key", s.Settings.LogdrainNewrelicLicenseKey)
}
addSection(" Metrics", "")
addSection(" History Days", s.Settings.SentinelMetricsHistoryDays)
addSection(" Refresh Rate", s.Settings.SentinelMetricsRefreshRateSeconds)
addSection(" Token", s.Settings.SentinelToken)
return builder.String()
}
ft := tui.NewTableFilter(items, columns, rowBuilder).
WithInitialFilter(initialFilter).
WithDetailView(detailBuilder)
return listModel{
filterableTable: ft,
servers: servers,
sensitive: sensitive,
filter: initialFilter,
}
}
// Implement Bubble Tea Model interface
func (m listModel) Init() tea.Cmd { return nil }
func (m listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.filterableTable.Update(msg)
}
func (m listModel) View() string {
return m.filterableTable.View()
}
func (c *cliServers) fetchServers(ctx context.Context) (*[]openapi.Server, error) {
req, err := c.coolify().Client.ListServers(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
parsedResponse, err := openapi.ParseListServersResponse(req)
if err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return parsedResponse.JSON200, nil
}
-66
View File
@@ -1,66 +0,0 @@
package cliservers
import (
"fmt"
"net/http"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
func (c *cliServers) newRemoveCommand() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "remove [uuid]",
Short: "Remove a server",
Long: `
Remove a server from your Coolify instance.
This action cannot be undone.`,
Example: utils.GetCommandExample(`
%[1]s servers remove [uuid]
%[1]s servers remove [uuid] --force`),
Aliases: []string{"delete", "rm"},
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
toRemove := args[0]
if !force {
fmt.Printf("Are you sure you want to remove the server with UUID '%s'? [y/N] ", toRemove)
var confirm string
_, err := fmt.Scanln(&confirm)
if err != nil {
return fmt.Errorf("failed to read confirmation: %w", err)
}
if confirm != "y" && confirm != "Y" {
fmt.Println("Operation cancelled")
return nil
}
}
response, err := c.coolify().Client.DeleteServerByUuid(cmd.Context(), toRemove)
if err != nil {
return fmt.Errorf("failed to remove server: %w", err)
}
parsedResponse, err := openapi.ParseDeleteServerByUuidResponse(response)
if err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if parsedResponse.StatusCode() != http.StatusOK {
switch parsedResponse.StatusCode() {
case http.StatusNotFound:
return fmt.Errorf("failed to remove server: %s", *parsedResponse.JSON404.Message)
default:
return fmt.Errorf("failed to remove server: %s", string(parsedResponse.Body))
}
}
fmt.Println(tui.SuccessStyle.Render(*parsedResponse.JSON200.Message))
return nil
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")
return cmd
}
-36
View File
@@ -1,36 +0,0 @@
package cliservers
import (
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/spf13/cobra"
)
type cliServers struct {
coolify runtime.Getter
}
func New(c runtime.Getter) *cliServers {
return &cliServers{
coolify: c,
}
}
// NewCommand creates and returns the servers command
func (c *cliServers) NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "servers",
Short: "Manage Coolify servers",
Long: `
Manage servers in your Coolify instance.
This command allows you to list, add, remove, and manage servers.`,
}
// Add subcommands
cmd.AddCommand(c.newListCommand())
cmd.AddCommand(c.newGetCommand())
cmd.AddCommand(c.newAddCommand())
cmd.AddCommand(c.newRemoveCommand())
cmd.AddCommand(c.newValidateCommand())
return cmd
}
-151
View File
@@ -1,151 +0,0 @@
package cliservers
import (
"context"
"fmt"
"net/http"
"time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
type validateModel struct {
spinner spinner.Model
uuid string
done bool
err error
response string
coolify runtime.Getter
ctx context.Context
}
type validateSuccessMsg struct {
message string
}
type validateErrorMsg struct {
err error
}
func (c *cliServers) newValidateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "validate [uuid]",
Short: "Validate server connection",
Long: `
Validate the connection to a server in your Coolify instance.
This will check if the server is reachable and usable.`,
Example: utils.GetCommandExample(`
%[1]s servers validate 123e4567-e89b-12d3-a456-426614174000`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uuid := args[0]
p := tea.NewProgram(initialValidateModel(uuid, c.coolify, cmd.Context()))
model, err := p.Run()
if err != nil {
return fmt.Errorf("error running validation: %w", err)
}
finalModel := model.(validateModel)
if finalModel.err != nil {
return finalModel.err
}
return nil
},
}
return cmd
}
func initialValidateModel(uuid string, coolify runtime.Getter, ctx context.Context) validateModel {
s := spinner.New()
s.Spinner = spinner.Points
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("99"))
return validateModel{
spinner: s,
uuid: uuid,
coolify: coolify,
ctx: ctx,
}
}
func (m validateModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
m.validateServer,
)
}
func (m validateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case validateSuccessMsg:
m.done = true
m.response = msg.message
return m, tea.Quit
case validateErrorMsg:
m.done = true
m.err = msg.err
return m, tea.Quit
}
return m, nil
}
func (m validateModel) View() string {
if m.done {
if m.err != nil {
return tui.ErrorStyle.Render(fmt.Sprintf("Error: %v\n", m.err))
}
return tui.SuccessStyle.Render(m.response + "\n")
}
return fmt.Sprintf("%s Validating server...\n", m.spinner.View())
}
func (m validateModel) validateServer() tea.Msg {
// Simulate network delay for better UX
time.Sleep(500 * time.Millisecond)
server, err := m.coolify().Client.ValidateServerByUuid(m.ctx, m.uuid)
if err != nil {
return validateErrorMsg{err: fmt.Errorf("failed to validate server: %w", err)}
}
parsedResponse, err := openapi.ParseValidateServerByUuidResponse(server)
if err != nil {
return validateErrorMsg{err: fmt.Errorf("failed to parse server response: %w", err)}
}
if parsedResponse.StatusCode() != http.StatusCreated {
switch parsedResponse.StatusCode() {
case http.StatusBadRequest:
return validateErrorMsg{err: fmt.Errorf("failed to validate server: %s", *parsedResponse.JSON400.Message)}
case http.StatusNotFound:
return validateErrorMsg{err: fmt.Errorf("failed to validate server: %s", *parsedResponse.JSON404.Message)}
default:
return validateErrorMsg{err: fmt.Errorf("failed to validate server: %s", string(parsedResponse.Body))}
}
}
return validateSuccessMsg{message: string(*parsedResponse.JSON201.Message)}
}
-84
View File
@@ -1,84 +0,0 @@
package cliupdate
import (
"fmt"
"runtime"
"strings"
coolifyRuntime "github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/coollabsio/cli-coolify/pkg/updater"
"github.com/spf13/cobra"
)
type cliUpdate struct {
coolify coolifyRuntime.Getter
}
func New(c coolifyRuntime.Getter) *cliUpdate {
return &cliUpdate{
coolify: c,
}
}
func (c *cliUpdate) NewCommand() *cobra.Command {
var preRelease bool
cmd := &cobra.Command{
Use: "update",
Short: "Update Coolify CLI",
Long: `
Update the Coolify CLI to the latest version from GitHub releases.
By default, the command will update to the latest stable version.
Use the --pre-release flag to update to the latest pre-release version.
`,
SilenceUsage: true,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// we should check if the current version is a pre-release
currentVersion := c.coolify().Version
isPreRelease := strings.Contains(currentVersion, "-")
// Create our custom updater
update := updater.New("coollabsio", "cli-coolify", c.coolify().Version)
// Check for updates
c.coolify().Logger.Infof("Checking for updates...")
// Check if an update is available without performing the update
release, hasUpdate, err := update.Check(cmd.Context(), preRelease)
if err != nil {
return fmt.Errorf("error checking for updates: %v", err)
}
if isPreRelease && !preRelease && !hasUpdate {
c.coolify().Logger.Warnf("You are on a pre-release version of the CLI. Use the --pre-release flag to update to the latest pre-release version.")
return nil
}
if !hasUpdate {
c.coolify().Logger.Infof("You are already on the latest version: %s\n", c.coolify().GetFormattedVersion())
return nil
}
c.coolify().Logger.Infof("Found new version: v%s (current: %s)\n", release.Version, c.coolify().GetFormattedVersion())
// Format OS/Arch for display
platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
c.coolify().Logger.Infof("Downloading update for %s...", platform)
// Perform the update
newVersion, err := update.To(cmd.Context(), release)
if err != nil {
return fmt.Errorf("update failed: %v", err)
}
c.coolify().Logger.Infof("Successfully updated to version v%s\n", newVersion)
return nil
},
}
flags := cmd.Flags()
flags.BoolVar(&preRelease, "pre-release", false, "Update to pre-release version")
return cmd
}
-33
View File
@@ -1,33 +0,0 @@
package cliversion
import (
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/spf13/cobra"
)
type cliVersion struct {
coolify runtime.Getter
}
func New(c runtime.Getter) *cliVersion {
return &cliVersion{
coolify: c,
}
}
func (c *cliVersion) NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "version ",
Short: "CLI version",
Long: `
Print the version of the CLI.
`,
SilenceUsage: true,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
cmd.Println(c.coolify().GetFormattedVersion())
},
}
return cmd
}
+97
View File
@@ -0,0 +1,97 @@
package completion
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
)
func NewCompletionsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "completion <shell>",
Short: "Output shell completion code for the specified shell",
Long: `To load completions:
### Bash
To load completions into the current shell execute:
source <(coolify completion bash)
In order to make the completions permanent, append the line above to
your .bashrc.
### Zsh
If shell completions are not already enabled for your environment need
to enable them. Add the following line to your ~/.zshrc file:
autoload -Uz compinit; compinit
To load completions for each session execute the following commands:
mkdir -p ~/.config/coolify/completion/zsh
coolify completion zsh > ~/.config/coolify/completion/zsh/_coolify
Finally add the following line to your ~/.zshrc file, *before* you
call the compinit function:
fpath+=(~/.config/coolify/completion/zsh)
In the end your ~/.zshrc file should contain the following two lines
in the order given here.
fpath+=(~/.config/coolify/completion/zsh)
# ... anything else that needs to be done before compinit
autoload -Uz compinit; compinit
# ...
You will need to start a new shell for this setup to take effect.
### Fish
To load completions into the current shell execute:
coolify completion fish | source
In order to make the completions permanent execute once:
coolify completion fish > ~/.config/fish/completions/coolify.fish
### PowerShell:
To load completions into the current shell execute:
PS> coolify completion powershell | Out-String | Invoke-Expression
To load completions for every new session, run
and source this file from your PowerShell profile.
PS> coolify completion powershell > coolify.ps1
`,
Args: cli.ExactArgs(1, "<shell>"),
ValidArgs: []string{"bash", "fish", "zsh", "powershell"},
DisableFlagsInUseLine: true,
RunE: func(cmd *cobra.Command, args []string) error {
var err error
switch args[0] {
case "bash":
err = cmd.Root().GenBashCompletion(os.Stdout)
case "fish":
err = cmd.Root().GenFishCompletion(os.Stdout, true)
case "zsh":
err = cmd.Root().GenZshCompletion(os.Stdout)
case "powershell":
err = cmd.Root().GenPowerShellCompletion(os.Stdout)
default:
err = fmt.Errorf("Unsupported shell: %s", args[0])
}
return err
},
}
return cmd
}
+21
View File
@@ -0,0 +1,21 @@
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/config"
)
// NewConfigCommand creates the config command
func NewConfigCommand() *cobra.Command {
return &cobra.Command{
Use: "config",
Short: "Show configuration file location",
Long: "Display the path to the Coolify CLI configuration file",
Run: func(_ *cobra.Command, _ []string) {
fmt.Println(config.Path())
},
}
}
+52
View File
@@ -0,0 +1,52 @@
package config
import (
"strings"
"testing"
"github.com/coollabsio/coolify-cli/internal/config"
)
func TestNewConfigCommand(t *testing.T) {
cmd := NewConfigCommand()
if cmd == nil {
t.Fatal("NewConfigCommand() returned nil")
}
if cmd.Use != "config" {
t.Errorf("Expected Use to be 'config', got '%s'", cmd.Use)
}
if cmd.Short == "" {
t.Error("Short description should not be empty")
}
if cmd.Long == "" {
t.Error("Long description should not be empty")
}
if cmd.Run == nil {
t.Error("Run function should not be nil")
}
}
func TestConfigCommand_Output(t *testing.T) {
// Test that the command returns the expected config path
expectedPath := config.Path()
// The path should not be empty
if expectedPath == "" {
t.Error("Expected config path to not be empty")
}
// The path should end with config.json
if !strings.HasSuffix(expectedPath, "config.json") {
t.Errorf("Expected path to end with 'config.json', got '%s'", expectedPath)
}
// The path should contain the coolify directory
if !strings.Contains(expectedPath, "coolify") {
t.Errorf("Expected path to contain 'coolify', got '%s'", expectedPath)
}
}
+93
View File
@@ -0,0 +1,93 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/config"
)
// NewAddCommand creates the add command
func NewAddCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "add <context_name> <url> <token>",
Example: `context add myserver https://coolify.example.com your-api-token`,
Args: cli.ExactArgs(3, "<context_name> <url> <token>"),
Short: "Add a new context",
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
host := args[1]
token := args[2]
force, _ := cmd.Flags().GetBool("force")
setDefault, _ := cmd.Flags().GetBool("default")
instances := viper.Get("instances").([]any)
// Check if instance already exists
for _, instance := range instances {
instanceMap := instance.(map[string]any)
if instanceMap["name"] == name {
if force {
instanceMap["token"] = token
if setDefault {
// Remove default from all instances
for _, inst := range instances {
instMap := inst.(map[string]any)
instMap["default"] = false
}
instanceMap["default"] = true
fmt.Printf("%s already exists. Force overwriting. Setting it as default.\n", name)
} else {
fmt.Printf("%s already exists. Force overwriting.\n", name)
}
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
fmt.Printf("%s already exists.\n", name)
fmt.Println("\nNote: Use --force to force overwrite.")
return nil
}
}
// Add new instance
newInstance := config.Instance{
Name: name,
FQDN: host,
Token: token,
Default: false,
}
if setDefault {
// Remove default from all instances
for _, inst := range instances {
instMap := inst.(map[string]any)
instMap["default"] = false
}
newInstance.Default = true
fmt.Printf("Context '%s' added and set as default.\n", newInstance.Name)
} else {
fmt.Printf("Context '%s' added successfully.\n", newInstance.Name)
}
instances = append(instances, newInstance)
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
},
}
cmd.Flags().BoolP("default", "d", false, "Set as default context")
cmd.Flags().BoolP("force", "f", false, "Force overwrite if context already exists")
return cmd
}
+28
View File
@@ -0,0 +1,28 @@
package context
import (
"github.com/spf13/cobra"
)
// NewContextCommand creates the context parent command
func NewContextCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "context",
Short: "Manage Coolify contexts",
Long: `Manage Coolify contexts. A context contains the configuration (URL and token) for connecting to Coolify.`,
}
// Add subcommands
cmd.AddCommand(NewListCommand())
cmd.AddCommand(NewAddCommand())
cmd.AddCommand(NewDeleteCommand())
cmd.AddCommand(NewUseCommand())
cmd.AddCommand(NewUpdateCommand())
cmd.AddCommand(NewGetCommand())
cmd.AddCommand(NewSetTokenCommand())
cmd.AddCommand(NewSetDefaultCommand())
cmd.AddCommand(NewVersionCommand())
cmd.AddCommand(NewVerifyCommand())
return cmd
}
+54
View File
@@ -0,0 +1,54 @@
package context
import (
"fmt"
"slices"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/cli"
)
// NewDeleteCommand creates the delete command
func NewDeleteCommand() *cobra.Command {
return &cobra.Command{
Use: "delete <context_name>",
Example: `context delete myserver`,
Args: cli.ExactArgs(1, "<context_name>"),
Short: "Delete a context",
RunE: func(_ *cobra.Command, args []string) error {
Name := args[0]
instances := viper.Get("instances").([]interface{})
for i, instance := range instances {
instanceMap := instance.(map[string]interface{})
if instanceMap["name"] == Name {
instances = slices.Delete(instances, i, i+1)
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
if instanceMap["default"] == true {
if len(instances) > 0 {
instances[0].(map[string]interface{})["default"] = true
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
newDefaultName := instances[0].(map[string]interface{})["name"]
fmt.Printf("Context '%s' deleted. '%s' is now the default context.\n", Name, newDefaultName)
} else {
fmt.Printf("Context '%s' deleted. No contexts remaining.\n", Name)
}
} else {
fmt.Printf("Context '%s' deleted.\n", Name)
}
return nil
}
}
return fmt.Errorf("context '%s' not found", Name)
},
}
}
+70
View File
@@ -0,0 +1,70 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/config"
"github.com/coollabsio/coolify-cli/internal/output"
)
// NewGetCommand creates the get command
func NewGetCommand() *cobra.Command {
return &cobra.Command{
Use: "get <context_name>",
Example: `context get myserver`,
Args: cli.ExactArgs(1, "<context_name>"),
Short: "Get details of a specific context",
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
instancesRaw := viper.Get("instances")
if instancesRaw == nil {
instancesRaw = []any{}
}
instancesInterface := instancesRaw.([]any)
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
// Convert interface{} to config.Instance structs
var instances []config.Instance
for _, item := range instancesInterface {
itemMap := item.(map[string]any)
instance := config.Instance{
Name: getString(itemMap, "name"),
FQDN: getString(itemMap, "fqdn"),
Token: getString(itemMap, "token"),
Default: getBool(itemMap, "default"),
}
instances = append(instances, instance)
}
// If a name was provided, filter to that single instance
var results []config.Instance
for _, inst := range instances {
if inst.Name == name {
results = append(results, inst)
break
}
}
if len(results) == 0 {
return fmt.Errorf("Context '%s' not found", name)
}
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return err
}
return formatter.Format(results)
},
}
}
+70
View File
@@ -0,0 +1,70 @@
package context
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/config"
"github.com/coollabsio/coolify-cli/internal/output"
)
// NewListCommand creates the list command
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all configured contexts",
RunE: func(cmd *cobra.Command, _ []string) error {
// Get instances from viper (returns []interface{})
instancesRaw := viper.Get("instances")
if instancesRaw == nil {
instancesRaw = []interface{}{}
}
instancesInterface := instancesRaw.([]interface{})
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
// Convert interface{} to config.Instance structs
var instances []config.Instance
for _, item := range instancesInterface {
itemMap := item.(map[string]any)
instance := config.Instance{
Name: getString(itemMap, "name"),
FQDN: getString(itemMap, "fqdn"),
Token: getString(itemMap, "token"),
Default: getBool(itemMap, "default"),
}
instances = append(instances, instance)
}
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return err
}
return formatter.Format(instances)
},
}
}
// Helper functions to safely extract values from map
func getString(m map[string]interface{}, key string) string {
if val, ok := m[key]; ok {
if str, ok := val.(string); ok {
return str
}
}
return ""
}
func getBool(m map[string]interface{}, key string) bool {
if val, ok := m[key]; ok {
if b, ok := val.(bool); ok {
return b
}
}
return false
}
+66
View File
@@ -0,0 +1,66 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/cli"
)
// NewSetTokenCommand creates the set-token command
func NewSetDefaultCommand() *cobra.Command {
return &cobra.Command{
Use: "set-default <context_name>",
Example: `context set-default myserver`,
Args: cli.ExactArgs(1, "<context_name>"),
Short: "Set a context as the default",
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
raw := viper.Get("instances")
instances, ok := raw.([]interface{})
if !ok {
return fmt.Errorf("invalid instances configuration")
}
// Check if instance exists
var found bool
for _, instance := range instances {
instanceMap, ok := instance.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid instance configuration")
}
if val, ok := instanceMap["name"].(string); ok && val == name {
found = true
instanceMap["default"] = true
}
}
if !found {
return fmt.Errorf("Context '%s' not found", name)
}
// Only unset other defaults if we found the target instance
for _, instance := range instances {
instanceMap, ok := instance.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid instance configuration")
}
if val, ok := instanceMap["name"].(string); ok && val != name {
instanceMap["default"] = false
}
}
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
// Show the list after updating
return NewListCommand().RunE(cmd, args)
},
}
}
+48
View File
@@ -0,0 +1,48 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/cli"
)
// NewSetTokenCommand creates the set-token command
func NewSetTokenCommand() *cobra.Command {
return &cobra.Command{
Use: "set-token <context_name> <token>",
Example: `context set-token myserver your-new-api-token`,
Args: cli.ExactArgs(2, "<context_name> <token>"),
Short: "Update the API token for a context",
RunE: func(_ *cobra.Command, args []string) error {
name := args[0]
token := args[1]
var found interface{}
for _, instance := range viper.Get("instances").([]interface{}) {
instanceMap := instance.(map[string]interface{})
if instanceMap["name"] == name {
found = instanceMap
break
}
}
if found == nil {
return fmt.Errorf("context '%s' not found", name)
}
instances := viper.Get("instances").([]interface{})
for _, instance := range instances {
instanceMap := instance.(map[string]interface{})
if instanceMap["name"] == name {
instanceMap["token"] = token
}
}
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to update token for context '%s': %w", name, err)
}
fmt.Printf("Token updated for context '%s'.\n", name)
return nil
},
}
}
+91
View File
@@ -0,0 +1,91 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/cli"
)
// NewUpdateCommand creates the update command
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <context_name>",
Example: `context update myserver --name newname --url https://new.coolify.com --token newtoken`,
Args: cli.ExactArgs(1, "<context_name>"),
Short: "Update a context's properties (name, URL, token)",
RunE: func(cmd *cobra.Command, args []string) error {
oldName := args[0]
instances := viper.Get("instances").([]interface{})
// Get flags
newName, _ := cmd.Flags().GetString("name")
newURL, _ := cmd.Flags().GetString("url")
newToken, _ := cmd.Flags().GetString("token")
// Check if at least one flag is provided
if newName == "" && newURL == "" && newToken == "" {
return fmt.Errorf("at least one of --name, --url, or --token must be provided")
}
// Find the context
var found bool
var contextToUpdate map[string]interface{}
for _, instance := range instances {
instanceMap := instance.(map[string]interface{})
if instanceMap["name"] == oldName {
found = true
contextToUpdate = instanceMap
break
}
}
if !found {
return fmt.Errorf("context '%s' not found", oldName)
}
// If renaming, check if new name already exists
if newName != "" && newName != oldName {
for _, instance := range instances {
instanceMap := instance.(map[string]interface{})
if instanceMap["name"] == newName {
return fmt.Errorf("context with name '%s' already exists", newName)
}
}
contextToUpdate["name"] = newName
}
// Update URL if provided
if newURL != "" {
contextToUpdate["fqdn"] = newURL
}
// Update token if provided
if newToken != "" {
contextToUpdate["token"] = newToken
}
// Save changes
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
// Use the new name if renamed, otherwise use old name
finalName := oldName
if newName != "" {
finalName = newName
}
fmt.Printf("Context '%s' updated successfully.\n", finalName)
return nil
},
}
cmd.Flags().StringP("name", "n", "", "New name for the context")
cmd.Flags().StringP("url", "u", "", "New URL for the context")
cmd.Flags().StringP("token", "t", "", "New token for the context")
return cmd
}
+67
View File
@@ -0,0 +1,67 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/cli"
)
// NewUseCommand creates the use command
func NewUseCommand() *cobra.Command {
return &cobra.Command{
Use: "use <context_name>",
Example: `context use myserver`,
Args: cli.ExactArgs(1, "<context_name>"),
Short: "Switch to a different context (set as default)",
RunE: func(_ *cobra.Command, args []string) error {
name := args[0]
raw := viper.Get("instances")
instances, ok := raw.([]interface{})
if !ok {
return fmt.Errorf("invalid instances configuration")
}
// Check if instance exists
var found bool
for _, instance := range instances {
instanceMap, ok := instance.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid instance configuration")
}
if val, ok := instanceMap["name"].(string); ok && val == name {
found = true
break
}
}
if !found {
return fmt.Errorf("Context '%s' not found", name)
}
// Update default
for _, instance := range instances {
instanceMap, ok := instance.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid instance configuration")
}
if val, ok := instanceMap["name"].(string); ok && val == name {
instanceMap["default"] = true
} else {
delete(instanceMap, "default")
}
}
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
fmt.Printf("Switched to context '%s'.\n", name)
return nil
},
}
}
+41
View File
@@ -0,0 +1,41 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
)
// NewVerifyCommand creates the verify command for contexts
func NewVerifyCommand() *cobra.Command {
return &cobra.Command{
Use: "verify",
Short: "Verify current context connection and authentication",
Long: `Verify that the current context is properly configured by testing the connection
to the Coolify instance and validating the API token.`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
// Get API client - this will use the current default context
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
// Try to get version - this verifies both connection and authentication
version, err := client.GetVersion(ctx)
if err != nil {
return fmt.Errorf("verification failed: %w", err)
}
// If we got here, connection and authentication are working
fmt.Printf("✓ Connection successful\n")
fmt.Printf("✓ Authentication valid\n")
fmt.Printf("✓ Coolify version: %s\n", version)
return nil
},
}
}
+106
View File
@@ -0,0 +1,106 @@
package context
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coollabsio/coolify-cli/internal/api"
)
// TestVerifyCommand_APIIntegration tests the verify logic using the API client directly
// This tests the core functionality that the verify command relies on
func TestVerifyCommand_APIIntegration(t *testing.T) {
t.Run("successful verification", func(t *testing.T) {
// Create a test HTTP server that responds to /api/v1/version
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/version", r.URL.Path)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("4.0.0-beta.383"))
}))
defer server.Close()
// Create API client and verify connection
client := api.NewClient(server.URL, "test-token")
version, err := client.GetVersion(context.Background())
// Verify results
require.NoError(t, err)
assert.Equal(t, "4.0.0-beta.383", version)
})
t.Run("unauthorized - invalid token", func(t *testing.T) {
// Create a test HTTP server that returns 401
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{
"message": "Invalid token",
})
}))
defer server.Close()
// Create API client with invalid token
client := api.NewClient(server.URL, "invalid-token")
_, err := client.GetVersion(context.Background())
// Verify error
require.Error(t, err)
assert.True(t, api.IsUnauthorized(err))
})
t.Run("server error", func(t *testing.T) {
// Create a test HTTP server that returns 500
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "Internal server error",
})
}))
defer server.Close()
// Create API client
client := api.NewClient(server.URL, "test-token", api.WithRetries(0))
_, err := client.GetVersion(context.Background())
// Verify error
require.Error(t, err)
var apiErr *api.Error
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, 500, apiErr.StatusCode)
})
t.Run("not found", func(t *testing.T) {
// Create a test HTTP server that returns 404
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]string{
"message": "Endpoint not found",
})
}))
defer server.Close()
// Create API client
client := api.NewClient(server.URL, "test-token")
_, err := client.GetVersion(context.Background())
// Verify error
require.Error(t, err)
assert.True(t, api.IsNotFound(err))
})
}
// TestNewVerifyCommand tests that the command is properly configured
func TestNewVerifyCommand(t *testing.T) {
cmd := NewVerifyCommand()
assert.Equal(t, "verify", cmd.Use)
assert.NotEmpty(t, cmd.Short)
assert.NotEmpty(t, cmd.Long)
assert.NotNil(t, cmd.RunE)
}
+35
View File
@@ -0,0 +1,35 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
)
// NewVersionCommand creates the version command for contexts
func NewVersionCommand() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Get current context's Coolify version",
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
// Get API client
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
// Get version using API client
version, err := client.GetVersion(ctx)
if err != nil {
return fmt.Errorf("failed to get version: %w", err)
}
fmt.Println(version)
return nil
},
}
}
-10
View File
@@ -1,10 +0,0 @@
package coolTypes
var Redacted = "********"
type Instance struct {
Name string `json:"name"`
Default bool `json:"default"`
Fqdn string `json:"fqdn"`
Token string `json:"token"`
}
+129
View File
@@ -0,0 +1,129 @@
package backup
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/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewCreateCommand creates a new database
func NewCreateCommand() *cobra.Command {
createBackupCmd := &cobra.Command{
Use: "create <database_uuid>",
Short: "Create a new scheduled backup configuration",
Long: `Create a new scheduled backup configuration for a database. Configure frequency, retention, S3 storage, and other backup options.
Example: coolify database backup create abc123 --frequency "0 0 * * *" --enabled`,
Args: cli.ExactArgs(1, "<database_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
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.436"); err != nil {
return err
}
req := &models.DatabaseBackupCreateRequest{}
// Apply flags if provided
if cmd.Flags().Changed("frequency") {
frequency, _ := cmd.Flags().GetString("frequency")
req.Frequency = &frequency
}
if cmd.Flags().Changed("enabled") {
enabled, _ := cmd.Flags().GetBool("enabled")
req.Enabled = &enabled
}
if cmd.Flags().Changed("save-s3") {
saveS3, _ := cmd.Flags().GetBool("save-s3")
req.SaveS3 = &saveS3
}
if cmd.Flags().Changed("s3-storage-uuid") {
s3UUID, _ := cmd.Flags().GetString("s3-storage-uuid")
req.S3StorageUUID = &s3UUID
}
if cmd.Flags().Changed("databases") {
databases, _ := cmd.Flags().GetString("databases")
req.DatabasesToBackup = &databases
}
if cmd.Flags().Changed("dump-all") {
dumpAll, _ := cmd.Flags().GetBool("dump-all")
req.DumpAll = &dumpAll
}
if cmd.Flags().Changed("retention-amount-locally") {
amount, _ := cmd.Flags().GetInt("retention-amount-locally")
req.DatabaseBackupRetentionAmountLocally = &amount
}
if cmd.Flags().Changed("retention-days-locally") {
days, _ := cmd.Flags().GetInt("retention-days-locally")
req.DatabaseBackupRetentionDaysLocally = &days
}
if cmd.Flags().Changed("retention-storage-locally") {
storage, _ := cmd.Flags().GetString("retention-storage-locally")
req.DatabaseBackupRetentionMaxStorageLocally = &storage
}
if cmd.Flags().Changed("retention-amount-s3") {
amount, _ := cmd.Flags().GetInt("retention-amount-s3")
req.DatabaseBackupRetentionAmountS3 = &amount
}
if cmd.Flags().Changed("retention-days-s3") {
days, _ := cmd.Flags().GetInt("retention-days-s3")
req.DatabaseBackupRetentionDaysS3 = &days
}
if cmd.Flags().Changed("retention-storage-s3") {
storage, _ := cmd.Flags().GetString("retention-storage-s3")
req.DatabaseBackupRetentionMaxStorageS3 = &storage
}
if cmd.Flags().Changed("timeout") {
timeout, _ := cmd.Flags().GetInt("timeout")
req.Timeout = &timeout
}
if cmd.Flags().Changed("disable-local") {
disableLocal, _ := cmd.Flags().GetBool("disable-local")
req.DisableLocalBackup = &disableLocal
}
dbService := service.NewDatabaseService(client)
backup, err := dbService.CreateBackup(ctx, dbUUID, req)
if err != nil {
return fmt.Errorf("failed to create backup: %w", err)
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(backup)
},
}
createBackupCmd.Flags().String("frequency", "", "Backup frequency (cron expression, e.g., '0 0 * * *' for daily)")
createBackupCmd.Flags().Bool("enabled", false, "Enable backup schedule")
createBackupCmd.Flags().Bool("save-s3", false, "Save backups to S3")
createBackupCmd.Flags().String("s3-storage-uuid", "", "S3 storage UUID")
createBackupCmd.Flags().String("databases-to-backup", "", "Comma-separated list of databases to backup")
createBackupCmd.Flags().Bool("dump-all", false, "Dump all databases")
createBackupCmd.Flags().Int("retention-amount-locally", 0, "Number of backups to retain locally")
createBackupCmd.Flags().Int("retention-days-locally", 0, "Days to retain backups locally")
createBackupCmd.Flags().String("retention-max-storage-locally", "", "Max storage for local backups (e.g., '1GB', '500MB')")
createBackupCmd.Flags().Int("retention-amount-s3", 0, "Number of backups to retain in S3")
createBackupCmd.Flags().Int("retention-days-s3", 0, "Days to retain backups in S3")
createBackupCmd.Flags().String("retention-max-storage-s3", "", "Max storage for S3 backups (e.g., '1GB', '500MB')")
createBackupCmd.Flags().Int("timeout", 0, "Backup timeout in seconds")
createBackupCmd.Flags().Bool("disable-local-backup", false, "Disable local backup storage")
return createBackupCmd
}
+63
View File
@@ -0,0 +1,63 @@
package backup
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDeleteExecutionCommand lists all databases
func NewDeleteExecutionCommand() *cobra.Command {
deleteBackupExecutionCmd := &cobra.Command{
Use: "delete-execution <database_uuid> <backup_uuid> <execution_uuid>",
Short: "Delete backup execution",
Long: `Delete a specific backup execution and optionally from S3. First UUID is the database, second is the backup configuration, third is the specific execution.`,
Args: cli.ExactArgs(3, "<database_uuid> <backup_uuid> <execution_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
backupUUID := args[1]
executionUUID := args[2]
force, _ := cmd.Flags().GetBool("force")
deleteS3, _ := cmd.Flags().GetBool("delete-s3")
if !force {
fmt.Printf("Are you sure you want to delete backup execution %s? (y/N): ", executionUUID)
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("error reading input: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Delete cancelled")
return nil
}
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
err = dbService.DeleteBackupExecution(ctx, dbUUID, backupUUID, executionUUID, deleteS3)
if err != nil {
return fmt.Errorf("failed to delete backup execution: %w", err)
}
fmt.Println("Backup execution deleted successfully")
return nil
},
}
deleteBackupExecutionCmd.Flags().Bool("delete-s3", false, "Delete backup file from S3")
return deleteBackupExecutionCmd
}
+62
View File
@@ -0,0 +1,62 @@
package backup
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDeleteCommand deletes a database
func NewDeleteCommand() *cobra.Command {
deleteBackupCmd := &cobra.Command{
Use: "delete <database_uuid> <backup_uuid>",
Short: "Delete backup configuration",
Long: `Delete a backup configuration and optionally all its executions from S3. First UUID is the database, second is the specific backup configuration.`,
Args: cli.ExactArgs(2, "<database_uuid> <backup_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
backupUUID := args[1]
force, _ := cmd.Flags().GetBool("force")
deleteS3, _ := cmd.Flags().GetBool("delete-s3")
if !force {
fmt.Printf("Are you sure you want to delete backup configuration %s? (y/N): ", backupUUID)
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("error reading input: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Delete cancelled")
return nil
}
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
err = dbService.DeleteBackup(ctx, dbUUID, backupUUID, deleteS3)
if err != nil {
return fmt.Errorf("failed to delete backup: %w", err)
}
fmt.Println("Backup configuration deleted successfully")
return nil
},
}
deleteBackupCmd.Flags().Bool("delete-s3", false, "Delete backup files from S3")
return deleteBackupCmd
}
+45
View File
@@ -0,0 +1,45 @@
package backup
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"
)
// NewExecutionCommand lists all databases
func NewExecutionCommand() *cobra.Command {
return &cobra.Command{
Use: "executions <database_uuid> <backup_uuid>",
Short: "List backup executions",
Long: `List all executions for a backup configuration. First UUID is the database, second is the specific backup configuration.`,
Args: cli.ExactArgs(2, "<database_uuid> <backup_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
backupUUID := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
executions, err := dbService.ListBackupExecutions(ctx, dbUUID, backupUUID)
if err != nil {
return fmt.Errorf("failed to list backup executions: %w", err)
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(executions)
},
}
}
+44
View File
@@ -0,0 +1,44 @@
package backup
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 lists all databases
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list <database_uuid>",
Short: "List all backup configurations for a database",
Long: `List all backup configurations for a specific database.`,
Args: cli.ExactArgs(1, "<database_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
backups, err := dbService.ListBackups(ctx, dbUUID)
if err != nil {
return fmt.Errorf("failed to list backups: %w", err)
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(backups)
},
}
}
+46
View File
@@ -0,0 +1,46 @@
package backup
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"
)
// NewTriggerCommand triggers a database backup
func NewTriggerCommand() *cobra.Command {
return &cobra.Command{
Use: "trigger <database_uuid> <backup_uuid>",
Short: "Trigger immediate backup",
Long: `Trigger an immediate backup for a specific backup configuration. First UUID is the database, second is the specific backup configuration to trigger.`,
Args: cli.ExactArgs(2, "<database_uuid> <backup_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
backupUUID := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
// Trigger immediate backup by updating with backup_now flag
req := &models.DatabaseBackupUpdateRequest{
BackupNow: cli.BoolPtr(true),
}
err = dbService.UpdateBackup(ctx, dbUUID, backupUUID, req)
if err != nil {
return fmt.Errorf("failed to trigger backup: %w", err)
}
fmt.Println("Immediate backup triggered successfully")
return nil
},
}
}
+125
View File
@@ -0,0 +1,125 @@
package backup
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 updates a database
func NewUpdateCommand() *cobra.Command {
updateBackupCmd := &cobra.Command{
Use: "update <database_uuid> <backup_uuid>",
Short: "Update backup configuration",
Long: `Update a backup configuration settings (frequency, retention, S3, etc.). First UUID is the database, second is the specific backup configuration.`,
Args: cli.ExactArgs(2, "<database_uuid> <backup_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
backupUUID := args[1]
req := &models.DatabaseBackupUpdateRequest{}
hasChanges := false
if cmd.Flags().Changed("enabled") {
enabled, _ := cmd.Flags().GetBool("enabled")
req.Enabled = &enabled
hasChanges = true
}
if cmd.Flags().Changed("frequency") {
freq, _ := cmd.Flags().GetString("frequency")
req.Frequency = &freq
hasChanges = true
}
if cmd.Flags().Changed("save-s3") {
saveS3, _ := cmd.Flags().GetBool("save-s3")
req.SaveS3 = &saveS3
hasChanges = true
}
if cmd.Flags().Changed("s3-storage-uuid") {
s3UUID, _ := cmd.Flags().GetString("s3-storage-uuid")
req.S3StorageUUID = &s3UUID
hasChanges = true
}
if cmd.Flags().Changed("databases-to-backup") {
dbs, _ := cmd.Flags().GetString("databases-to-backup")
req.DatabasesToBackup = &dbs
hasChanges = true
}
if cmd.Flags().Changed("dump-all") {
dumpAll, _ := cmd.Flags().GetBool("dump-all")
req.DumpAll = &dumpAll
hasChanges = true
}
// Retention settings
if cmd.Flags().Changed("retention-amount-locally") {
amount, _ := cmd.Flags().GetInt("retention-amount-locally")
req.DatabaseBackupRetentionAmountLocally = &amount
hasChanges = true
}
if cmd.Flags().Changed("retention-days-locally") {
days, _ := cmd.Flags().GetInt("retention-days-locally")
req.DatabaseBackupRetentionDaysLocally = &days
hasChanges = true
}
if cmd.Flags().Changed("retention-max-storage-locally") {
storage, _ := cmd.Flags().GetInt("retention-max-storage-locally")
req.DatabaseBackupRetentionMaxStorageLocally = &storage
hasChanges = true
}
if cmd.Flags().Changed("retention-amount-s3") {
amount, _ := cmd.Flags().GetInt("retention-amount-s3")
req.DatabaseBackupRetentionAmountS3 = &amount
hasChanges = true
}
if cmd.Flags().Changed("retention-days-s3") {
days, _ := cmd.Flags().GetInt("retention-days-s3")
req.DatabaseBackupRetentionDaysS3 = &days
hasChanges = true
}
if cmd.Flags().Changed("retention-max-storage-s3") {
storage, _ := cmd.Flags().GetInt("retention-max-storage-s3")
req.DatabaseBackupRetentionMaxStorageS3 = &storage
hasChanges = true
}
if !hasChanges {
return fmt.Errorf("no fields to update")
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
err = dbService.UpdateBackup(ctx, dbUUID, backupUUID, req)
if err != nil {
return fmt.Errorf("failed to update backup: %w", err)
}
fmt.Println("Backup configuration updated successfully")
return nil
},
}
updateBackupCmd.Flags().Bool("enabled", false, "Enable or disable backup")
updateBackupCmd.Flags().String("frequency", "", "Backup frequency (cron expression)")
updateBackupCmd.Flags().Bool("save-s3", false, "Save backups to S3")
updateBackupCmd.Flags().String("s3-storage-uuid", "", "S3 storage UUID")
updateBackupCmd.Flags().String("databases-to-backup", "", "Comma-separated list of databases to backup")
updateBackupCmd.Flags().Bool("dump-all", false, "Dump all databases")
updateBackupCmd.Flags().Int("retention-amount-locally", 0, "Number of backups to retain locally")
updateBackupCmd.Flags().Int("retention-days-locally", 0, "Days to retain backups locally")
updateBackupCmd.Flags().Int("retention-max-storage-locally", 0, "Max storage for local backups (MB)")
updateBackupCmd.Flags().Int("retention-amount-s3", 0, "Number of backups to retain in S3")
updateBackupCmd.Flags().Int("retention-days-s3", 0, "Days to retain backups in S3")
updateBackupCmd.Flags().Int("retention-max-storage-s3", 0, "Max storage for S3 backups (MB)")
return updateBackupCmd
}
+287
View File
@@ -0,0 +1,287 @@
package database
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/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewCreateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create <type>",
Short: "Create a new database",
Long: `Create a new database of the specified type.
Supported types: postgresql, mysql, mariadb, mongodb, redis, keydb, clickhouse, dragonfly
Examples:
coolify databases create postgresql --server-uuid=<uuid> --project-uuid=<uuid> --environment-name=production
coolify databases create mysql --server-uuid=<uuid> --project-uuid=<uuid> --environment-name=production --name="My MySQL"`,
Args: cli.ExactArgs(1, "<type>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbType := args[0]
validTypes := []string{"postgresql", "mysql", "mariadb", "mongodb", "redis", "keydb", "clickhouse", "dragonfly"}
isValid := false
for _, t := range validTypes {
if t == dbType {
isValid = true
break
}
}
if !isValid {
return fmt.Errorf("invalid database type '%s'. Valid types: %s", dbType, strings.Join(validTypes, ", "))
}
serverUUID, _ := cmd.Flags().GetString("server-uuid")
projectUUID, _ := cmd.Flags().GetString("project-uuid")
environmentName, _ := cmd.Flags().GetString("environment-name")
environmentUUID, _ := cmd.Flags().GetString("environment-uuid")
if serverUUID == "" || projectUUID == "" {
return fmt.Errorf("--server-uuid and --project-uuid are required")
}
if environmentName == "" && environmentUUID == "" {
return fmt.Errorf("either --environment-name or --environment-uuid must be provided")
}
req := &models.DatabaseCreateRequest{
ServerUUID: serverUUID,
ProjectUUID: projectUUID,
}
if environmentName != "" {
req.EnvironmentName = &environmentName
}
if environmentUUID != "" {
req.EnvironmentUUID = &environmentUUID
}
// Common flags
if cmd.Flags().Changed("name") {
name, _ := cmd.Flags().GetString("name")
req.Name = &name
}
if cmd.Flags().Changed("description") {
desc, _ := cmd.Flags().GetString("description")
req.Description = &desc
}
if cmd.Flags().Changed("image") {
image, _ := cmd.Flags().GetString("image")
req.Image = &image
}
if cmd.Flags().Changed("destination-uuid") {
dest, _ := cmd.Flags().GetString("destination-uuid")
req.DestinationUUID = &dest
}
if cmd.Flags().Changed("instant-deploy") {
instant, _ := cmd.Flags().GetBool("instant-deploy")
req.InstantDeploy = &instant
}
if cmd.Flags().Changed("is-public") {
isPublic, _ := cmd.Flags().GetBool("is-public")
req.IsPublic = &isPublic
}
if cmd.Flags().Changed("public-port") {
port, _ := cmd.Flags().GetInt("public-port")
req.PublicPort = &port
}
// Resource limits
if cmd.Flags().Changed("limits-memory") {
mem, _ := cmd.Flags().GetString("limits-memory")
req.LimitsMemory = &mem
}
if cmd.Flags().Changed("limits-cpus") {
cpus, _ := cmd.Flags().GetString("limits-cpus")
req.LimitsCpus = &cpus
}
// PostgreSQL specific
if dbType == "postgresql" {
if cmd.Flags().Changed("postgres-user") {
user, _ := cmd.Flags().GetString("postgres-user")
req.PostgresUser = &user
}
if cmd.Flags().Changed("postgres-password") {
pass, _ := cmd.Flags().GetString("postgres-password")
req.PostgresPassword = &pass
}
if cmd.Flags().Changed("postgres-db") {
db, _ := cmd.Flags().GetString("postgres-db")
req.PostgresDB = &db
}
}
// MySQL specific
if dbType == "mysql" {
if cmd.Flags().Changed("mysql-root-password") {
pass, _ := cmd.Flags().GetString("mysql-root-password")
req.MysqlRootPassword = &pass
}
if cmd.Flags().Changed("mysql-user") {
user, _ := cmd.Flags().GetString("mysql-user")
req.MysqlUser = &user
}
if cmd.Flags().Changed("mysql-password") {
pass, _ := cmd.Flags().GetString("mysql-password")
req.MysqlPassword = &pass
}
if cmd.Flags().Changed("mysql-database") {
db, _ := cmd.Flags().GetString("mysql-database")
req.MysqlDatabase = &db
}
}
// MariaDB specific
if dbType == "mariadb" {
if cmd.Flags().Changed("mariadb-root-password") {
pass, _ := cmd.Flags().GetString("mariadb-root-password")
req.MariadbRootPassword = &pass
}
if cmd.Flags().Changed("mariadb-user") {
user, _ := cmd.Flags().GetString("mariadb-user")
req.MariadbUser = &user
}
if cmd.Flags().Changed("mariadb-password") {
pass, _ := cmd.Flags().GetString("mariadb-password")
req.MariadbPassword = &pass
}
if cmd.Flags().Changed("mariadb-database") {
db, _ := cmd.Flags().GetString("mariadb-database")
req.MariadbDatabase = &db
}
}
// MongoDB specific
if dbType == "mongodb" {
if cmd.Flags().Changed("mongo-root-username") {
user, _ := cmd.Flags().GetString("mongo-root-username")
req.MongoInitdbRootUsername = &user
}
if cmd.Flags().Changed("mongo-root-password") {
pass, _ := cmd.Flags().GetString("mongo-root-password")
req.MongoInitdbRootPassword = &pass
}
if cmd.Flags().Changed("mongo-database") {
db, _ := cmd.Flags().GetString("mongo-database")
req.MongoInitdbDatabase = &db
}
}
// Redis specific
if dbType == "redis" {
if cmd.Flags().Changed("redis-password") {
pass, _ := cmd.Flags().GetString("redis-password")
req.RedisPassword = &pass
}
}
// KeyDB specific
if dbType == "keydb" {
if cmd.Flags().Changed("keydb-password") {
pass, _ := cmd.Flags().GetString("keydb-password")
req.KeydbPassword = &pass
}
}
// Clickhouse specific
if dbType == "clickhouse" {
if cmd.Flags().Changed("clickhouse-admin-user") {
user, _ := cmd.Flags().GetString("clickhouse-admin-user")
req.ClickhouseAdminUser = &user
}
if cmd.Flags().Changed("clickhouse-admin-password") {
pass, _ := cmd.Flags().GetString("clickhouse-admin-password")
req.ClickhouseAdminPassword = &pass
}
}
// Dragonfly specific
if dbType == "dragonfly" {
if cmd.Flags().Changed("dragonfly-password") {
pass, _ := cmd.Flags().GetString("dragonfly-password")
req.DragonflyPassword = &pass
}
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
database, err := dbService.Create(ctx, dbType, req)
if err != nil {
return fmt.Errorf("failed to create database: %w", err)
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(database)
},
}
// Common flags
cmd.Flags().String("server-uuid", "", "Server UUID (required)")
cmd.Flags().String("project-uuid", "", "Project UUID (required)")
cmd.Flags().String("environment-name", "", "Environment name")
cmd.Flags().String("environment-uuid", "", "Environment UUID")
cmd.Flags().String("destination-uuid", "", "Destination UUID if server has multiple destinations")
cmd.Flags().String("name", "", "Database name")
cmd.Flags().String("description", "", "Database description")
cmd.Flags().String("image", "", "Docker image")
cmd.Flags().Bool("instant-deploy", false, "Deploy immediately after creation")
cmd.Flags().Bool("is-public", false, "Make database publicly accessible")
cmd.Flags().Int("public-port", 0, "Public port")
cmd.Flags().String("limits-memory", "", "Memory limit (e.g., '512m', '2g')")
cmd.Flags().String("limits-cpus", "", "CPU limit (e.g., '0.5', '2')")
// PostgreSQL flags
cmd.Flags().String("postgres-user", "", "PostgreSQL user")
cmd.Flags().String("postgres-password", "", "PostgreSQL password")
cmd.Flags().String("postgres-db", "", "PostgreSQL database name")
// MySQL flags
cmd.Flags().String("mysql-root-password", "", "MySQL root password")
cmd.Flags().String("mysql-user", "", "MySQL user")
cmd.Flags().String("mysql-password", "", "MySQL password")
cmd.Flags().String("mysql-database", "", "MySQL database name")
// MariaDB flags
cmd.Flags().String("mariadb-root-password", "", "MariaDB root password")
cmd.Flags().String("mariadb-user", "", "MariaDB user")
cmd.Flags().String("mariadb-password", "", "MariaDB password")
cmd.Flags().String("mariadb-database", "", "MariaDB database name")
// MongoDB flags
cmd.Flags().String("mongo-root-username", "", "MongoDB root username")
cmd.Flags().String("mongo-root-password", "", "MongoDB root password")
cmd.Flags().String("mongo-database", "", "MongoDB database name")
// Redis flags
cmd.Flags().String("redis-password", "", "Redis password")
// KeyDB flags
cmd.Flags().String("keydb-password", "", "KeyDB password")
// Clickhouse flags
cmd.Flags().String("clickhouse-admin-user", "", "Clickhouse admin user")
cmd.Flags().String("clickhouse-admin-password", "", "Clickhouse admin password")
// Dragonfly flags
cmd.Flags().String("dragonfly-password", "", "Dragonfly password")
return cmd
}
+43
View File
@@ -0,0 +1,43 @@
package database
import (
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/cmd/database/backup"
)
// NewDatabaseCommand creates the database parent command with all subcommands
func NewDatabaseCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "database",
Aliases: []string{"databases", "db", "dbs"},
Short: "Manage Coolify databases",
Long: `Manage Coolify databases (PostgreSQL, MySQL, MongoDB, Redis, MariaDB, KeyDB, Clickhouse, Dragonfly).`,
}
// Add main database commands
cmd.AddCommand(NewListCommand())
cmd.AddCommand(NewGetCommand())
cmd.AddCommand(NewStartCommand())
cmd.AddCommand(NewStopCommand())
cmd.AddCommand(NewRestartCommand())
cmd.AddCommand(NewCreateCommand())
cmd.AddCommand(NewUpdateCommand())
cmd.AddCommand(NewDeleteCommand())
// Add backup subcommand
backupCmd := &cobra.Command{
Use: "backup",
Short: "Manage database backups",
}
backupCmd.AddCommand(backup.NewCreateCommand())
backupCmd.AddCommand(backup.NewListCommand())
backupCmd.AddCommand(backup.NewDeleteCommand())
backupCmd.AddCommand(backup.NewUpdateCommand())
backupCmd.AddCommand(backup.NewTriggerCommand())
backupCmd.AddCommand(backup.NewExecutionCommand())
backupCmd.AddCommand(backup.NewDeleteExecutionCommand())
cmd.AddCommand(backupCmd)
return cmd
}
+68
View File
@@ -0,0 +1,68 @@
package database
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDeleteCommand deletes a database
func NewDeleteCommand() *cobra.Command {
deleteDatabaseCmd := &cobra.Command{
Use: "delete <uuid>",
Short: "Delete a database",
Long: `Delete a database and optionally clean up its configurations, volumes, and networks.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
force, _ := cmd.Flags().GetBool("force")
deleteConfigurations, _ := cmd.Flags().GetBool("delete-configurations")
deleteVolumes, _ := cmd.Flags().GetBool("delete-volumes")
dockerCleanup, _ := cmd.Flags().GetBool("docker-cleanup")
deleteConnectedNetworks, _ := cmd.Flags().GetBool("delete-connected-networks")
if !force {
fmt.Printf("Are you sure you want to delete database %s? (y/N): ", uuid)
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("error reading input: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Delete cancelled")
return nil
}
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
err = dbService.Delete(ctx, uuid, deleteConfigurations, deleteVolumes, dockerCleanup, deleteConnectedNetworks)
if err != nil {
return fmt.Errorf("failed to delete database: %w", err)
}
fmt.Println("Database deleted successfully")
return nil
},
}
deleteDatabaseCmd.Flags().Bool("delete-configurations", true, "Delete configurations")
deleteDatabaseCmd.Flags().Bool("delete-volumes", true, "Delete volumes")
deleteDatabaseCmd.Flags().Bool("docker-cleanup", true, "Run docker cleanup")
deleteDatabaseCmd.Flags().Bool("delete-connected-networks", true, "Delete connected networks")
return deleteDatabaseCmd
}
+46
View File
@@ -0,0 +1,46 @@
package database
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"
)
// NewGetCommand gets database details
func NewGetCommand() *cobra.Command {
return &cobra.Command{
Use: "get <uuid>",
Short: "Get database details",
Long: `Get detailed information about a specific database by UUID.`,
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)
}
dbService := service.NewDatabaseService(client)
database, err := dbService.Get(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to get database: %w", err)
}
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
formatter, err := output.NewFormatter("table", output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(database)
},
}
}
+41
View File
@@ -0,0 +1,41 @@
package database
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 lists all databases
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all databases",
Long: `List all databases in Coolify.`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
databases, err := dbService.List(ctx)
if err != nil {
return fmt.Errorf("failed to list databases: %w", err)
}
formatter, err := output.NewFormatter("table", output.Options{})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(databases)
},
}
}
+38
View File
@@ -0,0 +1,38 @@
package database
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewRestartCommand restarts a database
func NewRestartCommand() *cobra.Command {
return &cobra.Command{
Use: "restart <uuid>",
Short: "Restart a database",
Long: `Restart a database by UUID.`,
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)
}
dbService := service.NewDatabaseService(client)
response, err := dbService.Restart(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to restart database: %w", err)
}
fmt.Println(response.Message)
return nil
},
}
}
+38
View File
@@ -0,0 +1,38 @@
package database
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewStartCommand starts a database
func NewStartCommand() *cobra.Command {
return &cobra.Command{
Use: "start <uuid>",
Short: "Start a database",
Long: `Start a database by UUID.`,
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)
}
dbService := service.NewDatabaseService(client)
response, err := dbService.Start(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to start database: %w", err)
}
fmt.Println(response.Message)
return nil
},
}
}
+38
View File
@@ -0,0 +1,38 @@
package database
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewStopCommand stops a database
func NewStopCommand() *cobra.Command {
return &cobra.Command{
Use: "stop <uuid>",
Short: "Stop a database",
Long: `Stop a database by UUID.`,
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)
}
dbService := service.NewDatabaseService(client)
response, err := dbService.Stop(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to stop database: %w", err)
}
fmt.Println(response.Message)
return nil
},
}
}
+116
View File
@@ -0,0 +1,116 @@
package database
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 updates a database
func NewUpdateCommand() *cobra.Command {
updateDatabaseCmd := &cobra.Command{
Use: "update <uuid>",
Short: "Update a database",
Long: `Update a database's configuration by UUID.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
req := &models.DatabaseUpdateRequest{}
hasChanges := false
if cmd.Flags().Changed("name") {
name, _ := cmd.Flags().GetString("name")
req.Name = &name
hasChanges = true
}
if cmd.Flags().Changed("description") {
desc, _ := cmd.Flags().GetString("description")
req.Description = &desc
hasChanges = true
}
if cmd.Flags().Changed("image") {
image, _ := cmd.Flags().GetString("image")
req.Image = &image
hasChanges = true
}
if cmd.Flags().Changed("is-public") {
isPublic, _ := cmd.Flags().GetBool("is-public")
req.IsPublic = &isPublic
hasChanges = true
}
if cmd.Flags().Changed("public-port") {
port, _ := cmd.Flags().GetInt("public-port")
req.PublicPort = &port
hasChanges = true
}
// Resource limits
if cmd.Flags().Changed("limits-memory") {
mem, _ := cmd.Flags().GetString("limits-memory")
req.LimitsMemory = &mem
hasChanges = true
}
if cmd.Flags().Changed("limits-cpus") {
cpus, _ := cmd.Flags().GetString("limits-cpus")
req.LimitsCpus = &cpus
hasChanges = true
}
if !hasChanges {
return fmt.Errorf("no fields to update")
}
// Validate is-public requires public-port
if req.IsPublic != nil && *req.IsPublic {
// If setting to public, check if port is provided or fetch current database to check existing port
if req.PublicPort == nil || *req.PublicPort == 0 {
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
currentDB, err := dbService.Get(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to get current database: %w", err)
}
// Check if database already has a public port
if currentDB.PublicPort == nil || *currentDB.PublicPort == 0 {
return fmt.Errorf("cannot set database as public without a public port. Please provide --public-port")
}
}
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
err = dbService.Update(ctx, uuid, req)
if err != nil {
return fmt.Errorf("failed to update database: %w", err)
}
fmt.Println("Database updated successfully")
return nil
},
}
updateDatabaseCmd.Flags().String("name", "", "Database name")
updateDatabaseCmd.Flags().String("description", "", "Database description")
updateDatabaseCmd.Flags().String("image", "", "Docker image")
updateDatabaseCmd.Flags().Bool("is-public", false, "Make database publicly accessible")
updateDatabaseCmd.Flags().Int("public-port", 0, "Public port")
updateDatabaseCmd.Flags().String("limits-memory", "", "Memory limit")
updateDatabaseCmd.Flags().String("limits-cpus", "", "CPU limit")
return updateDatabaseCmd
}
+131
View File
@@ -0,0 +1,131 @@
package deployment
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewBatchCommand deploys multiple resources by name
func NewBatchCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "batch <name1,name2,...>",
Short: "Deploy multiple resources by name",
Long: `Deploy multiple resources at once.
Provide resource names as comma-separated values.
Example: coolify deploy batch app1,app2,app3`,
Args: cli.ExactArgs(1, "<names>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
namesStr := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
// Parse comma-separated names
names := make([]string, 0)
for _, name := range strings.Split(namesStr, ",") {
name = strings.TrimSpace(name)
if name != "" {
names = append(names, name)
}
}
if len(names) == 0 {
return fmt.Errorf("no resource names provided")
}
// Find resources by name
resourceSvc := service.NewResourceService(client)
resources, err := resourceSvc.List(ctx)
if err != nil {
return fmt.Errorf("failed to list resources: %w", err)
}
// Build map of name -> UUID
nameToUUID := make(map[string]string)
for _, r := range resources {
nameToUUID[r.Name] = r.UUID
}
// Validate all names exist
var notFound []string
for _, name := range names {
if _, exists := nameToUUID[name]; !exists {
notFound = append(notFound, name)
}
}
if len(notFound) > 0 {
return fmt.Errorf("resources not found: %v", notFound)
}
// Deploy all resources
force, _ := cmd.Flags().GetBool("force")
deploySvc := service.NewDeploymentService(client)
type result struct {
Name string
UUID string
Success bool
Message string
Error string
}
results := make([]result, 0, len(names))
for _, name := range names {
uuid := nameToUUID[name]
fmt.Printf("Deploying %s...\n", name)
res, err := deploySvc.Deploy(ctx, uuid, force)
if err != nil {
results = append(results, result{
Name: name,
UUID: uuid,
Success: false,
Error: err.Error(),
})
fmt.Printf(" ❌ Failed: %v\n", err)
} else {
// Get first deployment message from the array
message := ""
if len(res.Deployments) > 0 {
message = res.Deployments[0].Message
}
results = append(results, result{
Name: name,
UUID: uuid,
Success: true,
Message: message,
})
fmt.Printf(" ✅ Success: %s\n", message)
}
}
// Summary
successCount := 0
for _, r := range results {
if r.Success {
successCount++
}
}
fmt.Printf("\nBatch deployment complete: %d/%d succeeded\n", successCount, len(results))
if successCount < len(results) {
return fmt.Errorf("some deployments failed")
}
return nil
},
}
cmd.Flags().Bool("force", false, "Force deployment")
return cmd
}
+75
View File
@@ -0,0 +1,75 @@
package deployment
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"
)
// NewCancelCommand cancels a deployment
func NewCancelCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cancel <uuid>",
Short: "Cancel a deployment by UUID",
Long: `Cancel an in-progress deployment. This will stop the deployment process and clean up any temporary resources.`,
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)
}
// Check minimum version requirement
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.436"); err != nil {
return err
}
force, err := cmd.Flags().GetBool("force")
if err != nil {
return fmt.Errorf("failed to parse force flag: %w", err)
}
// Prompt for confirmation unless --force is used
if !force {
fmt.Printf("Are you sure you want to cancel deployment %s? (yes/no): ", uuid)
var response string
if _, err := fmt.Scanln(&response); err != nil {
return fmt.Errorf("failed to read confirmation: %w", err)
}
if response != "yes" && response != "y" {
fmt.Println("Cancel aborted.")
return nil
}
}
deploySvc := service.NewDeploymentService(client)
result, err := deploySvc.Cancel(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to cancel deployment: %w", err)
}
format, err := cmd.Flags().GetString("format")
if err != nil {
return fmt.Errorf("failed to get format flag: %w", err)
}
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(result)
},
}
cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
return cmd
}
+21
View File
@@ -0,0 +1,21 @@
package deployment
import "github.com/spf13/cobra"
// NewDeploymentCommand creates the deployment parent command with all subcommands
func NewDeploymentCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "deploy",
Short: "Deploy related commands",
}
// Add all deployment subcommands
cmd.AddCommand(NewUUIDCommand())
cmd.AddCommand(NewNameCommand())
cmd.AddCommand(NewBatchCommand())
cmd.AddCommand(NewListCommand())
cmd.AddCommand(NewGetCommand())
cmd.AddCommand(NewCancelCommand())
return cmd
}
+44
View File
@@ -0,0 +1,44 @@
package deployment
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"
)
// NewGetCommand gets deployment details
func NewGetCommand() *cobra.Command {
return &cobra.Command{
Use: "get <uuid>",
Short: "Get deployment details by UUID",
Long: `Get detailed information about a specific deployment by its UUID.`,
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)
}
deploySvc := service.NewDeploymentService(client)
deployment, err := deploySvc.Get(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to get deployment: %w", err)
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(deployment)
},
}
}
+42
View File
@@ -0,0 +1,42 @@
package deployment
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 lists all deployments
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all deployments",
Long: `List all currently running deployments across all resources.`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
deploySvc := service.NewDeploymentService(client)
deployments, err := deploySvc.List(ctx)
if err != nil {
return fmt.Errorf("failed to list deployments: %w", err)
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(deployments)
},
}
}
+79
View File
@@ -0,0 +1,79 @@
package deployment
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"
)
// NewNameCommand deploys a resource by name
func NewNameCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "name <resource_name>",
Short: "Deploy by resource name",
Args: cli.ExactArgs(1, "<resource_name>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
name := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
// Find resource by name
resourceSvc := service.NewResourceService(client)
resources, err := resourceSvc.List(ctx)
if err != nil {
return fmt.Errorf("failed to list resources: %w", err)
}
var matchedUUID string
for _, r := range resources {
if r.Name == name {
matchedUUID = r.UUID
break
}
}
if matchedUUID == "" {
return fmt.Errorf("resource with name '%s' not found", name)
}
// Deploy using the found UUID
force, _ := cmd.Flags().GetBool("force")
deploySvc := service.NewDeploymentService(client)
result, err := deploySvc.Deploy(ctx, matchedUUID, force)
if err != nil {
return fmt.Errorf("failed to deploy resource: %w", err)
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return err
}
// For table format, convert deployment info array to display format
if format == output.FormatTable {
displays := make([]ResultDisplay, len(result.Deployments))
for i, dep := range result.Deployments {
displays[i] = ResultDisplay{
Message: dep.Message,
DeploymentUUID: dep.DeploymentUUID,
}
}
return formatter.Format(displays)
}
return formatter.Format(result)
},
}
cmd.Flags().Bool("force", false, "Force deployment")
return cmd
}
+65
View File
@@ -0,0 +1,65 @@
package deployment
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"
)
// ResultDisplay represents a deploy result for table display
type ResultDisplay struct {
Message string `json:"message"`
DeploymentUUID string `json:"deployment_uuid"`
}
// NewUUIDCommand deploys a resource by UUID
func NewUUIDCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "uuid <uuid>",
Short: "Deploy by uuid",
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)
}
force, _ := cmd.Flags().GetBool("force")
deploySvc := service.NewDeploymentService(client)
result, err := deploySvc.Deploy(ctx, uuid, force)
if err != nil {
return fmt.Errorf("failed to deploy resource: %w", err)
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return err
}
// For table format, convert deployment info array to display format
if format == output.FormatTable {
displays := make([]ResultDisplay, len(result.Deployments))
for i, dep := range result.Deployments {
displays[i] = ResultDisplay{
Message: dep.Message,
DeploymentUUID: dep.DeploymentUUID,
}
}
return formatter.Format(displays)
}
return formatter.Format(result)
},
}
cmd.Flags().Bool("force", false, "Force deployment")
return cmd
}
+96
View File
@@ -0,0 +1,96 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
)
var docsCmd = &cobra.Command{
Use: "docs",
Short: "Generate documentation",
Hidden: true,
}
var manCmd = &cobra.Command{
Use: "man",
Short: "Generate man pages",
Long: `Generate man pages for all Coolify CLI commands.
The man pages will be written to the specified directory (default: ./man).`,
Example: ` coolify docs man
coolify docs man --output-dir=/usr/local/share/man/man1`,
RunE: func(cmd *cobra.Command, _ []string) error {
outputDir, _ := cmd.Flags().GetString("output-dir")
// Create output directory if it doesn't exist
if err := os.MkdirAll(outputDir, 0750); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
// Generate man pages
header := &doc.GenManHeader{
Title: "COOLIFY",
Section: "1",
Source: "Coolify CLI",
}
if err := doc.GenManTree(rootCmd, header, outputDir); err != nil {
return fmt.Errorf("failed to generate man pages: %w", err)
}
absPath, _ := filepath.Abs(outputDir)
fmt.Printf("Man pages generated successfully in: %s\n", absPath)
fmt.Println("\nTo install the man pages system-wide:")
fmt.Println(" sudo cp man/*.1 /usr/local/share/man/man1/")
fmt.Println(" sudo mandb")
fmt.Println("\nTo view a man page:")
fmt.Println(" man coolify")
fmt.Println(" man coolify-servers")
return nil
},
}
var markdownCmd = &cobra.Command{
Use: "markdown",
Aliases: []string{"md"},
Short: "Generate markdown documentation",
Long: `Generate markdown documentation for all Coolify CLI commands.
The markdown files will be written to the specified directory (default: ./docs).`,
Example: ` coolify docs markdown
coolify docs markdown --output-dir=./documentation`,
RunE: func(cmd *cobra.Command, _ []string) error {
outputDir, _ := cmd.Flags().GetString("output-dir")
// Create output directory if it doesn't exist
if err := os.MkdirAll(outputDir, 0750); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
// Generate markdown docs
if err := doc.GenMarkdownTree(rootCmd, outputDir); err != nil {
return fmt.Errorf("failed to generate markdown docs: %w", err)
}
absPath, _ := filepath.Abs(outputDir)
fmt.Printf("Markdown documentation generated successfully in: %s\n", absPath)
return nil
},
}
func NewDocsCommand() *cobra.Command {
docsCmd.AddCommand(manCmd)
docsCmd.AddCommand(markdownCmd)
manCmd.Flags().StringP("output-dir", "o", "./man", "Output directory for man pages")
markdownCmd.Flags().StringP("output-dir", "o", "./docs", "Output directory for markdown files")
return docsCmd
}
-6
View File
@@ -1,6 +0,0 @@
package emoji
const (
CheckMarkButton = "\u2705" // ✅
CrossMark = "\u274c" // ❌
)
+53
View File
@@ -0,0 +1,53 @@
package github
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 NewListBranchesCommand() *cobra.Command {
return &cobra.Command{
Use: "branches <app_uuid> <owner/repo>",
Short: "List branches for a repository",
Long: `List all branches for a specific repository. Provide the app UUID and repository in owner/repo format.
Example: coolify github branches abc-123-def owner/repository`,
Args: cli.ExactArgs(2, "<app_uuid> <owner/repo>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
appUUID := args[0]
// Parse owner/repo
ownerRepo := args[1]
parts := cli.SplitOwnerRepo(ownerRepo)
if len(parts) != 2 {
return fmt.Errorf("invalid repository format. Expected 'owner/repo', got '%s'", ownerRepo)
}
owner, repo := parts[0], parts[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
svc := service.NewGitHubAppService(client)
branches, err := svc.ListBranches(ctx, appUUID, owner, repo)
if err != nil {
return fmt.Errorf("failed to list branches: %w", err)
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return err
}
return formatter.Format(branches)
},
}
}
+113
View File
@@ -0,0 +1,113 @@
package github
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/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewCreateCommand() *cobra.Command {
createCmd := &cobra.Command{
Use: "create",
Short: "Create a GitHub App integration",
Long: `Create a new GitHub App integration. This allows you to deploy private repositories from GitHub.
Required flags: --name, --api-url, --html-url, --app-id, --installation-id, --client-id, --client-secret, --private-key-uuid
Example: coolify github create --name "My GitHub App" --api-url "https://api.github.com" --html-url "https://github.com" --app-id 123456 --installation-id 789012 --client-id "Iv1.abc123" --client-secret "secret123" --private-key-uuid "abc-123-def-456"`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
name, _ := cmd.Flags().GetString("name")
apiURL, _ := cmd.Flags().GetString("api-url")
htmlURL, _ := cmd.Flags().GetString("html-url")
appID, _ := cmd.Flags().GetInt("app-id")
installationID, _ := cmd.Flags().GetInt("installation-id")
clientID, _ := cmd.Flags().GetString("client-id")
clientSecret, _ := cmd.Flags().GetString("client-secret")
privateKeyUUID, _ := cmd.Flags().GetString("private-key-uuid")
req := &models.GitHubAppCreateRequest{
Name: name,
APIURL: apiURL,
HTMLURL: htmlURL,
AppID: appID,
InstallationID: installationID,
ClientID: clientID,
ClientSecret: clientSecret,
PrivateKeyUUID: privateKeyUUID,
}
// Optional fields
if cmd.Flags().Changed("organization") {
org, _ := cmd.Flags().GetString("organization")
req.Organization = &org
}
if cmd.Flags().Changed("custom-user") {
user, _ := cmd.Flags().GetString("custom-user")
req.CustomUser = &user
}
if cmd.Flags().Changed("custom-port") {
port, _ := cmd.Flags().GetInt("custom-port")
req.CustomPort = &port
}
if cmd.Flags().Changed("webhook-secret") {
secret, _ := cmd.Flags().GetString("webhook-secret")
req.WebhookSecret = &secret
}
if cmd.Flags().Changed("system-wide") {
systemWide, _ := cmd.Flags().GetBool("system-wide")
req.IsSystemWide = &systemWide
}
svc := service.NewGitHubAppService(client)
app, err := svc.Create(ctx, req)
if err != nil {
return fmt.Errorf("failed to create GitHub App: %w", err)
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return err
}
return formatter.Format(app)
},
}
createCmd.Flags().String("name", "", "GitHub App name (required)")
createCmd.Flags().String("organization", "", "GitHub organization")
createCmd.Flags().String("api-url", "", "GitHub API URL (required, e.g., https://api.github.com)")
createCmd.Flags().String("html-url", "", "GitHub HTML URL (required, e.g., https://github.com)")
createCmd.Flags().String("custom-user", "", "Custom user for SSH (default: git)")
createCmd.Flags().Int("custom-port", 0, "Custom port for SSH (default: 22)")
createCmd.Flags().Int("app-id", 0, "GitHub App ID (required)")
createCmd.Flags().Int("installation-id", 0, "GitHub Installation ID (required)")
createCmd.Flags().String("client-id", "", "GitHub OAuth Client ID (required)")
createCmd.Flags().String("client-secret", "", "GitHub OAuth Client Secret (required)")
createCmd.Flags().String("webhook-secret", "", "GitHub Webhook Secret")
createCmd.Flags().String("private-key-uuid", "", "UUID of existing private key (required)")
createCmd.Flags().Bool("system-wide", false, "Is this app system-wide (cloud only)")
_ = createCmd.MarkFlagRequired("name")
_ = createCmd.MarkFlagRequired("api-url")
_ = createCmd.MarkFlagRequired("html-url")
_ = createCmd.MarkFlagRequired("app-id")
_ = createCmd.MarkFlagRequired("installation-id")
_ = createCmd.MarkFlagRequired("client-id")
_ = createCmd.MarkFlagRequired("client-secret")
_ = createCmd.MarkFlagRequired("private-key-uuid")
return createCmd
}
+59
View File
@@ -0,0 +1,59 @@
package github
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewDeleteCommand() *cobra.Command {
deleteCmd := &cobra.Command{
Use: "delete <app_uuid>",
Short: "Delete a GitHub App integration",
Long: `Delete a GitHub App integration. The app must not be used by any applications.`,
Args: cli.ExactArgs(1, "<app_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
appUUID := args[0]
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 GitHub App %s? This cannot be undone. (yes/no): ", appUUID)
_, err := fmt.Scanln(&response)
if err != nil {
return fmt.Errorf("failed to read confirmation: %w", err)
}
if response != "yes" && response != "y" {
fmt.Println("Delete cancelled.")
return nil
}
}
svc := service.NewGitHubAppService(client)
err = svc.Delete(ctx, appUUID)
if err != nil {
return fmt.Errorf("failed to delete GitHub App: %w", err)
}
fmt.Println("GitHub App deleted successfully")
return nil
},
}
deleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
return deleteCmd
}
+43
View File
@@ -0,0 +1,43 @@
package github
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 <app_uuid>",
Short: "Get GitHub App details by UUID",
Long: `Get detailed information about a specific GitHub App integration.`,
Args: cli.ExactArgs(1, "<app_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
appUUID := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
svc := service.NewGitHubAppService(client)
app, err := svc.Get(ctx, appUUID)
if err != nil {
return fmt.Errorf("failed to get GitHub App: %w", err)
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return err
}
return formatter.Format(app)
},
}
}
+25
View File
@@ -0,0 +1,25 @@
package github
import (
"github.com/spf13/cobra"
)
func NewGitHubCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "github",
Aliases: []string{"gh", "github-app", "github-apps"},
Short: "Manage GitHub App integrations",
Long: `Manage GitHub App integrations for private repository deployments.`,
}
// Add main database commands
cmd.AddCommand(NewListCommand())
cmd.AddCommand(NewGetCommand())
cmd.AddCommand(NewCreateCommand())
cmd.AddCommand(NewUpdateCommand())
cmd.AddCommand(NewDeleteCommand())
cmd.AddCommand(NewListRepositoriesCommand())
cmd.AddCommand(NewListBranchesCommand())
return cmd
}
+46
View File
@@ -0,0 +1,46 @@
package github
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",
Short: "List all GitHub App integrations",
Long: `List all GitHub App integrations configured in Coolify.`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
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.436"); err != nil {
return err
}
svc := service.NewGitHubAppService(client)
apps, err := svc.List(ctx)
if err != nil {
return fmt.Errorf("failed to list GitHub Apps: %w", err)
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return err
}
return formatter.Format(apps)
},
}
}

Some files were not shown because too many files have changed in this diff Show More