Compare commits

...

27 Commits

Author SHA1 Message Date
Laurence 9a128de992 enhance: fixes and update goreleaser to not set rc as latest 2025-04-21 09:59:20 +01:00
Laurence 6495804344 enhance: various enhancements and adding our own updater package 2025-04-20 13:42:59 +01:00
Laurence 3a994cb19e enhance: various enhancements and adding gocritic to improve codebase 2025-04-20 11:38:53 +01:00
Laurence 9b8992d177 enhance: private key fixes 2025-04-19 13:44:13 +01:00
Laurence de6418e532 enhance: Allow private key generation output to be optional and add a force if they want to overwrite on filesystem 2025-04-19 13:39:36 +01:00
Laurence 5e8c823637 enhance: Various enhancements 2025-04-19 13:20:07 +01:00
Laurence 7c370540e2 enhance: Updates and fix vagrant init password generator 2025-04-15 07:52:22 +01:00
Laurence 35f152b3d1 enhance: Switch over to client SDK codegen, note it current panics in servers list 2025-04-11 22:27:30 +01:00
Laurence 2b8a3bd120 enhance: Remove altscreen for now 2025-04-06 00:45:52 +01:00
Laurence 77a61d614e enhance: Fix filterabletable deletion and use filtertable in cliinstances 2025-04-06 00:29:10 +01:00
Laurence 255b918d02 enhance: Create filtertable reuseable component will expand to other commands 2025-04-05 22:58:50 +01:00
Laurence 200313c1b8 enhance: Expand private keys functions, Create pkg/tui which is a helper to generate branded terminal UI items 2025-04-05 19:58:42 +01:00
Laurence dd0d46b0fc enhance: Add vagrant file to automated setting up a local coolify for cli testing 2025-04-05 15:02:32 +01:00
Laurence 7c6a6b4292 wip: Start implemented privatekeys functionality (not tested) 2025-04-02 18:32:46 +01:00
Laurence ef4a847f10 wip: fix goreleaser title the os 2025-04-01 13:02:11 +01:00
Laurence b22f7b6943 wip: Rename repo from coolify-cli to cli-coolify 2025-04-01 12:56:42 +01:00
Laurence 9a4ef0d6ac wip: Fixes and general updates 2025-04-01 12:47:29 +01:00
Laurence 98a624af27 wip: more changes 2025-04-01 12:20:18 +01:00
Laurence cb185da557 wip: Model changed, Using text inputs provided by bubbles instead of computing it overselves 2025-04-01 09:33:20 +01:00
Laurence d809990bec wip: init is now pretty 2025-03-31 19:29:23 +01:00
Laurence f66c4f4217 wip: init now uses bubbletea 2025-03-31 19:18:58 +01:00
Laurence decc3e092a wip: readd the update command 2025-03-31 18:24:56 +01:00
Laurence 611b14d2ea wip: update list to use new table 2025-03-31 18:15:57 +01:00
Laurence d22e6607a9 wip: update cursorrules and vibe code 2025-03-31 17:37:52 +01:00
Laurence 1126defb7c wip: update cursorrules and vibe code 2025-03-31 17:37:30 +01:00
Laurence b4148d6344 wip 2025-03-23 18:30:41 +00:00
Laurence 8c38a5447a wip: started refactoring, need to work on implementing the rest of v0.0.1 commands but built a baseline 2025-03-22 17:50:46 +00:00
65 changed files with 20665 additions and 1722 deletions
+38 -25
View File
@@ -1,29 +1,42 @@
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.
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.
Always use Go 1.24 and be familiar with CLI development best practices, Go idioms, and terminal UI design principles.
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 using lipgloss for terminal styling, use these Coolify brand colors via the pkg/tui 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.
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.
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.
- 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.
+6 -1
View File
@@ -1,4 +1,9 @@
coolify-cli
cli-coolify
coolify
cli
config.json
config.json
dist
.vagrant
.test
+22
View File
@@ -0,0 +1,22 @@
version: "2"
linters:
enable:
- gocritic
settings:
gocritic:
enabled-tags:
- diagnostic
- style
- performance
disabled-checks:
- hugeParam
- rangeValCopy
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
exclusions:
paths:
- "pkg/gen/*.go"
+13 -1
View File
@@ -10,5 +10,17 @@ builds:
goarch:
- amd64
- arm64
ldflags:
- -s -w -X github.com/coollabsio/cli-coolify/cmd/runtime.Version={{.Version}}
env:
- CGO_ENABLED=0
- 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 }}"
+69 -16
View File
@@ -1,35 +1,88 @@
# 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/coolify-cli/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/coollabsio/cli-coolify/main/scripts/install.sh | bash
```
It will install the CLI in `/usr/local/bin/coolify` and the configuration file in `~/.config/coolify/config.json`
This will install the CLI in `/usr/local/bin/coolify`.
> If you are a windows or mac user, please test the installation script and let us know if it works for you.
> If you are a Windows or macOS user, please test the installation script and let us know if it works for you.
## Configuration
1. Get a `<token>` from your Coolify dashboard (Cloud or self-hosted) at `/security/api-tokens`
## Initial Setup
### Cloud
Before using any commands, you need to initialize the CLI by creating a configuration file:
2. Add the token with `coolify instances set token cloud <token>`
```bash
coolify init
```
### Self-hosted
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
2. Add the token with `coolify instances add -d <name> <fqdn> <token>`
> Replace `<name>` with the name you want to give to the instance.
>
> Replace `<fqdn>` with the fully qualified domain name of your Coolify instance.
Alternatively, you can generate a default configuration non-interactively:
Now you can use the CLI with the token you just added.
```bash
coolify init --default
```
The configuration will be stored in `~/.config/coolify/config.json`.
## Getting Your API Token
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
After initialization, you can manage your Coolify instances:
### Add a New Instance
```bash
coolify instances add MyInstance https://my.instance.tld mytoken
```
Or use the interactive mode:
```bash
coolify instances add
```
### 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
```
## Change default instance
You can change the default instance with `coolify instances set default <name>`
## Currently Supported Commands
### Update
- `coolify update` - Update the CLI to the latest version
+46
View File
@@ -0,0 +1,46 @@
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
@@ -0,0 +1,104 @@
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
@@ -0,0 +1,448 @@
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
@@ -0,0 +1,128 @@
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
@@ -0,0 +1,297 @@
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
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,204 @@
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
@@ -0,0 +1,51 @@
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
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,59 @@
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
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,439 @@
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
@@ -0,0 +1,266 @@
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
@@ -0,0 +1,203 @@
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
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,70 @@
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
@@ -0,0 +1,319 @@
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
@@ -0,0 +1,163 @@
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
@@ -0,0 +1,216 @@
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
@@ -0,0 +1,66 @@
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
@@ -0,0 +1,36 @@
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
@@ -0,0 +1,151 @@
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
@@ -0,0 +1,84 @@
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
@@ -0,0 +1,33 @@
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
}
+10
View File
@@ -0,0 +1,10 @@
package coolTypes
var Redacted = "********"
type Instance struct {
Name string `json:"name"`
Default bool `json:"default"`
Fqdn string `json:"fqdn"`
Token string `json:"token"`
}
-79
View File
@@ -1,79 +0,0 @@
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"log"
"github.com/spf13/cobra"
)
type Deploy struct {
Deployments []Deployment `json:"deployments"`
}
type Deployment struct {
Message string `json:"message"`
ResourceUuid string `json:"resource_uuid"`
DeploymentUuid string `json:"deployment_uuid"`
}
var deployCmd = &cobra.Command{
Use: "deploy",
Short: "Deploy related commands",
}
var deployByUuidCmd = &cobra.Command{
Use: "uuid <uuid>",
Short: "Deploy by uuid",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
var CsvUuids = ""
for _, uuid := range args {
CsvUuids += uuid + ","
}
CsvUuids = CsvUuids[:len(CsvUuids)-1]
data, err := Fetch("deploy?uuid=" + CsvUuids)
if err != nil {
log.Println(err)
return
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata Deploy
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(w, "Message\tResource Uuid\tDeployment Uuid")
for _, resource := range jsondata.Deployments {
fmt.Fprintf(w, "%s\t%s\t%s\n", resource.Message, resource.ResourceUuid, resource.DeploymentUuid)
}
w.Flush()
},
}
// TODO deployByTagCmd
func init() {
rootCmd.AddCommand(deployCmd)
deployCmd.AddCommand(deployByUuidCmd)
}
-68
View File
@@ -1,68 +0,0 @@
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"log"
"github.com/spf13/cobra"
)
type Domain struct {
IP string `json:"ip"`
Domains []string `json:"domains"`
}
var domainsCmd = &cobra.Command{
Use: "domains",
Short: "Domain related commands",
}
var listDomainsCmd = &cobra.Command{
Use: "list",
Short: "List all domains",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
data, err := Fetch("domains")
if err != nil {
log.Println(err)
return
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata []Domain
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(w, "IP Address\tDomains")
for _, resource := range jsondata {
for _, domain := range resource.Domains {
fmt.Fprintf(w, "%s\t%s\n", resource.IP, domain)
}
}
w.Flush()
},
}
func init() {
// rootCmd.AddCommand(domainsCmd)
// domainsCmd.AddCommand(listDomainsCmd)
}
+6
View File
@@ -0,0 +1,6 @@
package emoji
const (
CheckMarkButton = "\u2705" // ✅
CrossMark = "\u274c" // ❌
)
-306
View File
@@ -1,306 +0,0 @@
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var instancesCmd = &cobra.Command{
Use: "instances",
Short: "Coolify instance related commands.",
}
var instanceVersionCmd = &cobra.Command{
Use: "version",
Short: "Get instance version.",
Run: func(cmd *cobra.Command, args []string) {
data, err := Fetch("version")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(data)
},
}
var listInstancesCmd = &cobra.Command{
Use: "list",
Short: "List all Coolify instances.",
Run: func(cmd *cobra.Command, args []string) {
instances := viper.Get("instances").([]interface{})
if PrettyMode {
var prettyJSON bytes.Buffer
instancesBytes, err := json.Marshal(instances)
if err != nil {
fmt.Println(err)
return
}
err = json.Indent(&prettyJSON, instancesBytes, "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(prettyJSON.String())
return
}
if JsonMode {
instancesBytes, err := json.Marshal(instances)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(instancesBytes))
return
}
fmt.Fprintln(w, "#\tName\tFqdn\tToken\tDefault")
for index, entry := range instances {
entryMap, ok := entry.(map[string]interface{})
if !ok {
fmt.Println("Error")
return
}
if ShowSensitive {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", index+1, entryMap["name"], entryMap["fqdn"], entryMap["token"], map[bool]string{true: "true", false: ""}[entryMap["default"] == true])
} else {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", index+1, entryMap["name"], entryMap["fqdn"], SensitiveInformationOverlay, map[bool]string{true: "true", false: ""}[entryMap["default"] == true])
}
}
w.Flush()
fmt.Println("\nNote: Use -s to show sensitive information.")
},
}
var addInstanceCmd = &cobra.Command{
Use: "add",
Example: `add <instanceName> <fqdn> <token>`,
Args: cobra.ExactArgs(3),
Short: "Add a Coolify instance.",
Run: func(cmd *cobra.Command, args []string) {
Name := args[0]
Host := args[1]
Token := args[2]
instances := viper.Get("instances").([]interface{})
for _, instance := range instances {
instanceMap := instance.(map[string]interface{})
if instanceMap["name"] == Name {
if Force {
instanceMap["token"] = Token
if SetDefaultInstance {
for _, instance := range instances {
instanceMap := instance.(map[string]interface{})
delete(instanceMap, "default")
}
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)
viper.WriteConfig()
return
}
fmt.Printf("%s already exists. \n", Name)
fmt.Println("\nNote: Use -f to force overwrite.")
return
}
}
instances = append(instances, map[string]interface{}{
"name": Name,
"fqdn": Host,
"token": Token,
})
if SetDefaultInstance {
for _, instance := range instances {
instanceMap := instance.(map[string]interface{})
delete(instanceMap, "default")
}
instances[len(instances)-1].(map[string]interface{})["default"] = true
}
viper.Set("instances", instances)
viper.WriteConfig()
listInstancesCmd.Run(cmd, args)
},
}
var removeInstanceCmd = &cobra.Command{
Use: "remove",
Example: `remove <instanceName>`,
Args: cobra.ExactArgs(1),
Short: "Remove a Coolify instance.",
Run: func(cmd *cobra.Command, args []string) {
Name := args[0]
instances := viper.Get("instances").([]interface{})
for i, instance := range instances {
instanceMap := instance.(map[string]interface{})
if instanceMap["name"] == Name {
instances = append(instances[:i], instances[i+1:]...)
viper.Set("instances", instances)
viper.WriteConfig()
fmt.Printf("%s removed. \n", Name)
if instanceMap["default"] == true {
fmt.Println("Note: The default instance has been removed.")
if len(instances) > 0 {
instances[0].(map[string]interface{})["default"] = true
viper.Set("instances", instances)
viper.WriteConfig()
fmt.Printf("%s set as default. \n", instances[0].(map[string]interface{})["fqdn"])
}
}
return
}
}
fmt.Printf("%s not found. \n", Name)
},
}
var setCmd = &cobra.Command{
Use: "set",
Args: cobra.ExactArgs(2),
Short: "Set default instance or token.",
Run: func(cmd *cobra.Command, args []string) {
},
}
var setTokenCmd = &cobra.Command{
Use: "token",
Example: `set token <instanceName> "<token>"`,
Args: cobra.ExactArgs(2),
Short: "Set token for the given Coolify instance.",
Run: func(cmd *cobra.Command, args []string) {
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 {
fmt.Printf("%s instance is not found. \n", Name)
return
}
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)
viper.WriteConfig()
listInstancesCmd.Run(cmd, args)
},
}
var setDefaultCmd = &cobra.Command{
Use: "default",
Example: `set default <instanceName>`,
Args: cobra.ExactArgs(1),
Short: "Set the default Coolify instance.",
Run: func(cmd *cobra.Command, args []string) {
Name := args[0]
instances := viper.Get("instances").([]interface{})
var found interface{}
for _, instance := range instances {
instanceMap := instance.(map[string]interface{})
if instanceMap["name"] == Name {
found = instanceMap
break
}
}
if found == nil {
fmt.Printf("%s not found. \n", Name)
return
}
for _, instance := range instances {
instanceMap := instance.(map[string]interface{})
if instanceMap["name"] == Name {
instanceMap["default"] = true
} else {
delete(instanceMap, "default")
}
}
viper.Set("instances", instances)
viper.WriteConfig()
listInstancesCmd.Run(cmd, args)
},
}
var getInstanceCmd = &cobra.Command{
Use: "get",
Example: `config get <instanceName>`,
Args: cobra.ExactArgs(1),
Short: "Get a Coolify instance.",
Run: func(cmd *cobra.Command, args []string) {
Name := args[0]
instances := viper.Get("instances").([]interface{})
if PrettyMode {
var prettyJSON bytes.Buffer
for _, instance := range instances {
instanceMap := instance.(map[string]interface{})
instanceMap["token"] = SensitiveInformationOverlay
}
instancesBytes, err := json.Marshal(instances)
if err != nil {
fmt.Println(err)
return
}
err = json.Indent(&prettyJSON, instancesBytes, "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(prettyJSON.String())
return
}
if JsonMode {
for _, instance := range instances {
instanceMap := instance.(map[string]interface{})
instanceMap["token"] = SensitiveInformationOverlay
}
instancesBytes, err := json.Marshal(instances)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(instancesBytes))
return
}
for _, instance := range instances {
instanceMap := instance.(map[string]interface{})
if instanceMap["name"] == Name {
fmt.Fprintln(w, "Name\tHost\tToken")
if ShowSensitive {
fmt.Fprintf(w, "%s\t%s\t%s\n", Name, instanceMap["fqdn"], instanceMap["token"])
} else {
fmt.Fprintf(w, "%s\t%s\t%s\n", Name, instanceMap["fqdn"], SensitiveInformationOverlay)
}
w.Flush()
fmt.Println("\nNote: Use -s to show sensitive information.")
return
}
}
fmt.Printf("%s not found. \n", Name)
},
}
func init() {
addInstanceCmd.Flags().BoolVarP(&SetDefaultInstance, "default", "d", false, "Set default instance")
rootCmd.AddCommand(instancesCmd)
instancesCmd.AddCommand(instanceVersionCmd)
instancesCmd.AddCommand(listInstancesCmd)
instancesCmd.AddCommand(addInstanceCmd)
instancesCmd.AddCommand(removeInstanceCmd)
instancesCmd.AddCommand(setCmd)
instancesCmd.AddCommand(getInstanceCmd)
setCmd.AddCommand(setTokenCmd)
setCmd.AddCommand(setDefaultCmd)
}
-192
View File
@@ -1,192 +0,0 @@
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"log"
"os"
"strings"
"github.com/spf13/cobra"
)
type PrivateKeys struct {
PrivateKeys []PrivateKey `json:"private_keys"`
}
type PrivateKey struct {
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
PublicKey string `json:"public_key"`
PrivateKey string `json:"private_key"`
}
var privateKeysCmd = &cobra.Command{
Use: "private-keys",
Short: "Private key related commands",
}
var listPrivateKeysCmd = &cobra.Command{
Use: "list",
Short: "List all private keys",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "security/keys"
data, err := Fetch(baseUrl)
if err != nil {
log.Println(err)
return
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata []PrivateKey
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(w, "Uuid\tName")
for _, resource := range jsondata {
if ShowSensitive {
fmt.Fprintf(w, "%s\t%s\n", resource.UUID, resource.Name)
} else {
fmt.Fprintf(w, "%s\t%s\n", resource.UUID, resource.Name)
}
}
w.Flush()
fmt.Println("\nNote: Use -s to show sensitive information.")
},
}
var onePrivateKeyCmd = &cobra.Command{
Use: "get [uuid]",
Args: cobra.ExactArgs(1),
Short: "Get private key details by uuid",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "security/keys/"
uuid := args[0]
var url = baseUrl + uuid
data, err := Fetch(url)
if err != nil {
fmt.Println(err)
return
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata PrivateKey
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(w, "Uuid\tName\tPublicKey\tPrivateKey")
if ShowSensitive {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", jsondata.UUID, jsondata.Name, jsondata.PublicKey, strings.ReplaceAll(jsondata.PrivateKey, "\n", "\\n"))
} else {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", jsondata.UUID, jsondata.Name, SensitiveInformationOverlay, SensitiveInformationOverlay)
}
w.Flush()
fmt.Println("\nNote: Use -s to show sensitive information.")
},
}
var addPrivateKeyCmd = &cobra.Command{
Use: "add",
Example: `add <name> <private_key_or_file>`,
Args: cobra.ExactArgs(2),
Short: "Add a private key",
Run: func(cmd *cobra.Command, args []string) {
version := "4.0.0-beta.383"
CheckDefaultThings(&version)
baseUrl := "security/keys"
name := args[0]
privateKeyInput := args[1]
var privateKey string
// Check if input is a file path
if _, err := os.Stat(privateKeyInput); err == nil {
keyBytes, err := os.ReadFile(privateKeyInput)
if err != nil {
fmt.Printf("Error reading private key file: %v\n", err)
return
}
privateKey = string(keyBytes)
} else {
privateKey = privateKeyInput
}
data := map[string]string{
"name": name,
"private_key": privateKey,
}
jsonData, err := json.Marshal(data)
if err != nil {
fmt.Printf("Error creating request: %v\n", err)
return
}
_, err = Post(baseUrl, bytes.NewBuffer(jsonData))
if err != nil {
fmt.Printf("Error adding private key: %v\n", err)
return
}
fmt.Printf("Private key '%s' added successfully\n", name)
},
}
var removePrivateKeyCmd = &cobra.Command{
Use: "remove [uuid]",
Args: cobra.ExactArgs(1),
Short: "Remove a private key",
Run: func(cmd *cobra.Command, args []string) {
version := "4.0.0-beta.383"
CheckDefaultThings(&version)
baseUrl := "security/keys/"
uuid := args[0]
_, err := Delete(baseUrl + uuid)
if err != nil {
fmt.Println(err)
return
}
fmt.Println("Private key removed successfully")
},
}
func init() {
rootCmd.AddCommand(privateKeysCmd)
privateKeysCmd.AddCommand(listPrivateKeysCmd)
privateKeysCmd.AddCommand(onePrivateKeyCmd)
privateKeysCmd.AddCommand(addPrivateKeyCmd)
privateKeysCmd.AddCommand(removePrivateKeyCmd)
}
-211
View File
@@ -1,211 +0,0 @@
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"log"
"strings"
"github.com/spf13/cobra"
)
type Application struct {
ID int `json:"id"`
Uuid string `json:"uuid"`
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
}
type Environment struct {
ID int `json:"id"`
Uuid string `json:"uuid"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Description *string `json:"description"`
Applications []Application `json:"applications"`
}
type Project struct {
Uuid string `json:"uuid"`
Name string `json:"name"`
Environments []Environment `json:"environments"`
}
var projectsCmd = &cobra.Command{
Use: "projects",
Short: "Project related commands",
}
var listProjectsCmd = &cobra.Command{
Use: "list",
Short: "List all projects",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "projects"
data, err := Fetch(baseUrl)
if err != nil {
log.Println(err)
return
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata []Project
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(w, "Uuid\tName")
for _, resource := range jsondata {
fmt.Fprintf(w, "%s\t%s\n", resource.Uuid, resource.Name)
}
w.Flush()
},
}
var oneProjectCmd = &cobra.Command{
Use: "get [uuid]",
Short: "Get a project by uuid",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
uuid := args[0]
environment, _ := cmd.Flags().GetString("environment")
if environment != "" {
url := "projects/" + uuid + "/" + environment
data, err := Fetch(url)
if err != nil {
log.Println(err)
return
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata Environment
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
log.Println(err)
return
}
fmt.Fprintln(w, "Uuid\tName\tStatus")
for _, resource := range jsondata.Applications {
fmt.Fprintf(w, "%s\t%s\t%s\n", resource.Uuid, resource.Name, resource.Status)
}
w.Flush()
return
}
data, err := Fetch("projects/" + uuid)
if err != nil {
log.Println(err)
return
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata Project
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
log.Println(err)
return
}
fmt.Fprintln(w, "Uuid\tName\tEnvironments")
envNames := make([]string, len(jsondata.Environments))
for i, env := range jsondata.Environments {
envNames[i] = env.Name + " (" + env.Uuid + ")"
}
fmt.Fprintf(w, "%s\t%s\t%s\n", jsondata.Uuid, jsondata.Name, strings.Join(envNames, ", "))
w.Flush()
},
}
var addProjectCmd = &cobra.Command{
Use: "add [name]",
Short: "Add a project",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "projects"
name := args[0]
data := map[string]string{
"name": name,
}
jsonData, err := json.Marshal(data)
if err != nil {
fmt.Println(err)
return
}
response, err := Post(baseUrl, bytes.NewBuffer(jsonData))
if err != nil {
fmt.Println(err)
return
}
msg := map[string]string{}
json.Unmarshal([]byte(response), &msg)
fmt.Println("Project added successfully with uuid " + msg["uuid"])
},
}
var removeProjectCmd = &cobra.Command{
Use: "remove [uuid]",
Short: "Remove a project",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "projects/"
uuid := args[0]
response, err := Delete(baseUrl + uuid)
if err != nil {
fmt.Println(err)
return
}
msg := map[string]string{}
json.Unmarshal([]byte(response), &msg)
fmt.Println(msg["message"])
},
}
func init() {
rootCmd.AddCommand(projectsCmd)
projectsCmd.AddCommand(listProjectsCmd)
oneProjectCmd.Flags().StringP("environment", "e", "", "Environment")
projectsCmd.AddCommand(oneProjectCmd)
projectsCmd.AddCommand(addProjectCmd)
projectsCmd.AddCommand(removeProjectCmd)
}
-58
View File
@@ -1,58 +0,0 @@
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"log"
"github.com/spf13/cobra"
)
var resourcesCmd = &cobra.Command{
Use: "resources",
Short: "Resource related commands",
}
var listResourcesCmd = &cobra.Command{
Use: "list",
Short: "List all resources",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
data, err := Fetch("resources")
if err != nil {
log.Println(err)
return
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata []Resource
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(w, "Uuid\tName\tType\tStatus")
for _, resource := range jsondata {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t\n", resource.Uuid, resource.Name, resource.Type, resource.Status)
}
w.Flush()
},
}
func init() {
rootCmd.AddCommand(resourcesCmd)
resourcesCmd.AddCommand(listResourcesCmd)
}
+60 -325
View File
@@ -1,352 +1,87 @@
package cmd
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"sort"
"text/tabwriter"
"time"
"github.com/adrg/xdg"
compareVersion "github.com/hashicorp/go-version"
"github.com/coollabsio/cli-coolify/cmd/cliinit"
"github.com/coollabsio/cli-coolify/cmd/cliinstances"
"github.com/coollabsio/cli-coolify/cmd/cliprivatekeys"
"github.com/coollabsio/cli-coolify/cmd/cliservers"
"github.com/coollabsio/cli-coolify/cmd/cliupdate"
"github.com/coollabsio/cli-coolify/cmd/cliversion"
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var CliVersion = "0.0.1"
var LastUpdateCheckTime time.Time
var CheckInverval = 10 * time.Minute
var (
coolify *runtime.Coolify
)
var ConfigDir = xdg.ConfigHome
var Version string
var Name string
var Fqdn string
var Token string
var Instance http.Client
var SensitiveInformationOverlay = "********"
// Flags
var Debug bool
var ShowSensitive bool
var Force bool
var Format string
var JsonMode bool
var PrettyMode bool
var SetDefaultInstance bool
var w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.Debug)
type Tag struct {
Ref string `json:"ref"`
type cliRoot struct {
outputFormat string
fqdn string
token string
name string
timeout time.Duration
insecure bool
logLevel string
}
var rootCmd = &cobra.Command{
Use: "coolify",
Short: "Coolify CLI",
Long: `A CLI tool to interact with Coolify API.`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return nil
},
func NewCliRoot() *cliRoot {
return &cliRoot{}
}
func CheckFormat(format string) {
if format == "json" {
JsonMode = true
return
}
if format == "pretty" {
PrettyMode = true
return
}
if format == "table" {
return
}
fmt.Println("Invalid format", format)
os.Exit(0)
func (cli *cliRoot) runtime() *runtime.Coolify {
return coolify
}
func CheckDefaultThings(version *string) {
FetchVersion()
CheckFormat(Format)
if version == nil {
CheckMinimumVersion(Version)
} else {
CheckMinimumVersion(*version)
}
func (cli *cliRoot) initialize() error {
coolify = runtime.NewCoolify(cli.fqdn, cli.token, cli.logLevel)
// Log initialization message
coolify.LogTrace("Initializing Coolify CLI with log level: %s", cli.logLevel)
// Use the new load method on the Coolify struct
return coolify.Load(cli.name)
}
func CheckMinimumVersion(version string) {
requiredVersion, err := compareVersion.NewVersion(version)
if err != nil {
log.Println(err)
os.Exit(0)
}
currentVersion, err := compareVersion.NewVersion(Version)
if err != nil {
log.Println(err)
os.Exit(0)
}
if currentVersion.LessThan(requiredVersion) {
log.Printf("Minimum required Coolify API version is: %s\n", version)
log.Print("Please upgrade your Coolify instance for this command.\n")
os.Exit(0)
}
}
func FetchVersion() (string, error) {
data, err := Fetch("version")
if err != nil {
log.Println(err)
os.Exit(0)
return "", err
}
Version = data
return data, nil
}
func Fetch(url string) (string, error) {
url = Fqdn + "/api/v1/" + url
if Debug {
log.Println("Fetching data from", url)
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Add("Authorization", "Bearer "+Token)
resp, err := Instance.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("%d - Failed to fetch data from %s. Error: %s", resp.StatusCode, url, string(body))
func (cli *cliRoot) NewCommand() (*cobra.Command, error) {
cmd := &cobra.Command{
Use: "coolify",
Short: "Coolify CLI",
Long: `A CLI tool to interact with Coolify API.`,
}
return string(body), nil
}
func Post(url string, input io.Reader) (string, error) {
url = Fqdn + "/api/v1/" + url
if Debug {
log.Println("Posting data to", url)
}
req, err := http.NewRequest("POST", url, input)
if err != nil {
return "", err
}
req.Header.Add("Authorization", "Bearer "+Token)
req.Header.Add("Content-Type", "application/json")
resp, err := Instance.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode >= 400 {
message := string(body)
if message == "" {
message = "Unknown error"
} else {
msg := map[string]string{}
json.Unmarshal(body, &msg)
message = msg["message"]
}
return "", fmt.Errorf("%s (rc: %d)", message, resp.StatusCode)
}
pFlags := cmd.PersistentFlags()
return string(body), nil
}
pFlags.StringVar(&cli.token, "token", "", "Token for authentication (https://app.coolify.io/security/api-tokens)")
pFlags.StringVar(&cli.fqdn, "host", "", "Coolify instance hostname EG: https://app.coolify.io")
pFlags.StringVarP(&cli.name, "name", "n", "", "Name of the instance to use from configuration file")
pFlags.StringVar(&cli.outputFormat, "format", "table", "Format output (table|json|pretty)")
pFlags.Bool("disableColor", false, "Disable color output for table format")
pFlags.DurationVar(&cli.timeout, "timeout", 30*time.Second, "HTTP client timeout")
pFlags.BoolVar(&cli.insecure, "insecure", false, "Skip TLS verification")
pFlags.StringVar(&cli.logLevel, "log-level", "info", "Set log level (trace|debug|info|warn|error|fatal|panic)")
func Delete(url string) (string, error) {
url = Fqdn + "/api/v1/" + url
if Debug {
log.Println("Deleting data from", url)
}
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return "", err
}
req.Header.Add("Authorization", "Bearer "+Token)
resp, err := Instance.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != 200 {
message := string(body)
if message == "" {
message = "Unknown error"
} else {
msg := map[string]string{}
json.Unmarshal(body, &msg)
message = msg["message"]
}
return "", fmt.Errorf("%s (rc: %d)", message, resp.StatusCode)
}
return string(body), nil
}
cmd.AddCommand(cliinit.New(cli.runtime).NewCommand())
cmd.AddCommand(cliinstances.New(cli.runtime).NewCommand())
cmd.AddCommand(cliversion.New(cli.runtime).NewCommand())
cmd.AddCommand(cliupdate.New(cli.runtime).NewCommand())
cmd.AddCommand(cliprivatekeys.New(cli.runtime).NewCommand())
cmd.AddCommand(cliservers.New(cli.runtime).NewCommand())
func CheckLatestVersionOfCli() (string, error) {
getLastUpdateCheckTime()
if LastUpdateCheckTime.Add(CheckInverval).After(time.Now()) {
if Debug {
log.Println("Skipping update check. Last check was less than 10 minutes ago.")
}
return CliVersion, nil
}
setLastUpdateCheckTime()
url := "https://api.github.com/repos/coollabsio/coolify-cli/git/refs/tags"
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("%d - Failed to fetch data from %s. Error: %s", resp.StatusCode, url, string(body))
}
var tags []Tag
if err := json.Unmarshal(body, &tags); err != nil {
return "", err
}
versionsRaw := make([]string, 0, len(tags))
for _, tag := range tags {
versionStr := tag.Ref[10:]
versionsRaw = append(versionsRaw, versionStr)
}
versions := make([]*compareVersion.Version, len(versionsRaw))
for i, raw := range versionsRaw {
v, err := compareVersion.NewVersion(raw)
if err != nil {
return "", err
}
versions[i] = v
}
sort.Sort(compareVersion.Collection(versions))
latestVersion := versions[len(versions)-1].String()
if latestVersion != CliVersion {
fmt.Printf("There is a new version of Coolify CLI available.\nPlease update with 'coolify --update'.\n\n")
}
return latestVersion, nil
}
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(0)
}
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVarP(&Token, "token", "", "", "Token for authentication (https://app.coolify.io/security/api-tokens)")
rootCmd.PersistentFlags().StringVarP(&Fqdn, "host", "", "", "Coolify instance hostname")
rootCmd.PersistentFlags().StringVarP(&Format, "format", "", "table", "Format output (table|json|pretty)")
rootCmd.PersistentFlags().BoolVarP(&ShowSensitive, "show-sensitive", "s", false, "Show sensitive information")
rootCmd.PersistentFlags().BoolVarP(&Force, "force", "f", false, "Force")
rootCmd.PersistentFlags().BoolVarP(&Debug, "debug", "", false, "Debug mode")
}
func setLastUpdateCheckTime() {
timeNow := time.Now()
viper.Set("lastupdatechecktime", timeNow)
viper.WriteConfig()
LastUpdateCheckTime = timeNow
}
func getLastUpdateCheckTime() {
lastUpdateCheckTimeString := viper.Get("lastupdatechecktime").(string)
lastUpdateCheckTime, err := time.Parse(time.RFC3339, lastUpdateCheckTimeString)
if err != nil {
log.Fatalf("Error parsing time: %v", err)
}
LastUpdateCheckTime = lastUpdateCheckTime
}
func initConfig() {
viper.SetConfigName("config")
viper.SetConfigType("json")
viper.AddConfigPath(ConfigDir + "/coolify")
if _, err := os.Stat(ConfigDir + "/coolify"); os.IsNotExist(err) {
os.MkdirAll(ConfigDir+"/coolify", 0755)
}
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
log.Println("Config file not found. Creating a new one at", ConfigDir+"/coolify/config.json")
viper.Set("lastUpdateCheckTime", time.Now())
viper.Set("instances", []interface{}{map[string]interface{}{
"name": "cloud",
"default": true,
"fqdn": "https://app.coolify.io",
"token": "",
if len(os.Args) > 1 {
cobra.OnInitialize(
func() {
if err := cli.initialize(); err != nil {
// handle it in future
log.Println(err)
os.Exit(1)
}
},
})
viper.Set("instances", append(viper.Get("instances").([]interface{}), map[string]interface{}{
"name": "localhost",
"fqdn": "http://localhost:8000",
"token": "",
}))
viper.SafeWriteConfig()
return
// Config file not found; ignore error if desired
} else {
fmt.Println("Error reading config file, ", err)
return
// Config file was found but another error was produced
}
}
if Debug {
log.Println("Using config file:", viper.ConfigFileUsed())
}
instancesMap := viper.Get("instances").([]interface{})
for _, instance := range instancesMap {
instanceMap := instance.(map[string]interface{})
if instanceMap["default"] == true {
if Fqdn == "" {
Fqdn = instanceMap["fqdn"].(string)
}
if Token == "" {
Token = instanceMap["token"].(string)
}
}
}
data, err := CheckLatestVersionOfCli()
if err != nil {
log.Println(err)
}
if data != CliVersion {
log.Printf("New version of Coolify CLI is available: %s\n", data)
)
}
return cmd, nil
}
+233
View File
@@ -0,0 +1,233 @@
package runtime
import (
"context"
"fmt"
"net/http"
"os"
"path"
"time"
"github.com/adrg/xdg"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
)
// Package runtime provides a reuseable struct that holds configuration, http client and other common functions shared by all the commands.
var (
// Version will be injected during build by goreleaser, without the 'v' prefix
Version = "0.0.0-dev"
DefaultConfigDirectory string = xdg.ConfigHome // Currently using xdg.ConfigHome but maybe we can expose this as a flag in future.
)
type Getter func() *Coolify
type Config struct {
Directory string
FQDN string
Token string
JsonExists bool
Timeout time.Duration
Insecure bool
}
type Coolify struct {
Version string
Config Config
Client *openapi.Client
Logger *logrus.Logger
}
func NewCoolify(fqdn, token, logLevel string) *Coolify {
// Initialize logger with default settings
logger := logrus.New()
logger.SetFormatter(&logrus.TextFormatter{
DisableTimestamp: true,
})
// Create the Coolify instance
coolify := &Coolify{
Version: Version,
Config: Config{
Directory: DefaultConfigDirectory,
FQDN: fqdn,
Token: token,
JsonExists: false,
Timeout: 30 * time.Second,
Insecure: false,
},
Logger: logger,
}
// Set the log level immediately
if logLevel != "" {
coolify.SetLogLevel(logLevel)
}
return coolify
}
func (c *Coolify) ConfigureClient() error {
withApiPrefix := fmt.Sprintf("%s/api/v1", c.Config.FQDN)
client, err := openapi.NewClient(withApiPrefix)
if err != nil {
c.LogError("Failed to create client: %v", err)
return err
}
// Add token to all requests via client interceptor
client.RequestEditors = append(client.RequestEditors, func(ctx context.Context, req *http.Request) error {
req.Header.Set("Authorization", "Bearer "+c.Config.Token)
return nil
})
c.Client = client
return nil
}
// GetFormattedVersion returns the version with 'v' prefix for display
func (c *Coolify) GetFormattedVersion() string {
// Tags on GitHub don't have 'v' prefix, but we want to display it
return fmt.Sprintf("v%s", c.Version)
}
// Load reads the configuration file from the default directory and loads it into the Coolify struct.
func (c *Coolify) Load(instanceName string) error {
baseDir := path.Join(c.Config.Directory, "coolify")
viper.SetConfigType("json")
viper.AddConfigPath(baseDir)
c.LogDebug("Loading configuration from: %s", baseDir)
if _, err := os.Stat(baseDir); os.IsNotExist(err) {
c.LogDebug("Configuration directory does not exist: %s", baseDir)
return nil // we return nil here because if the configuration directory doesnt exist, then the config file also doesnt exist.
}
if err := viper.ReadInConfig(); err != nil {
c.LogError("Failed to read configuration file: %v", err)
return err // we return the error here because if the configuration directory exists, then the config file should also exist and not error.
}
c.LogDebug("Configuration file loaded successfully")
c.Config.JsonExists = true
if viper.Get("instances") != nil {
instances := make([]coolTypes.Instance, 0)
if err := viper.UnmarshalKey("instances", &instances); err != nil {
c.LogError("Failed to unmarshal instances: %v", err)
return err
}
// if fqdn and token are not set, then we will set them to the default instance or name if provided from flags
if c.Config.FQDN == "" && c.Config.Token == "" {
c.LogDebug("FQDN and Token not provided via flags, looking for instance: %s", instanceName)
for _, instance := range instances {
if (instanceName != "" && instance.Name == instanceName) || (instance.Default && instanceName == "") {
c.LogDebug("Using instance: %s with FQDN: %s", instance.Name, instance.Fqdn)
c.Config.FQDN = instance.Fqdn
c.Config.Token = instance.Token
break
}
}
}
}
return c.ConfigureClient()
}
// Save saves the configuration file
func (c *Coolify) Save() error {
baseDir := path.Join(c.Config.Directory, "coolify")
c.LogDebug("Saving configuration to: %s", baseDir)
if _, err := os.Stat(baseDir); os.IsNotExist(err) {
c.LogDebug("Creating configuration directory: %s", baseDir)
if err := os.MkdirAll(baseDir, 0o755); err != nil {
c.LogError("Failed to create configuration directory: %v", err)
return err
}
}
var err error
if c.Config.JsonExists {
c.LogDebug("Updating existing configuration file")
err = viper.WriteConfig()
} else {
c.LogDebug("Creating new configuration file")
err = viper.SafeWriteConfig()
}
if err != nil {
c.LogError("Failed to save configuration: %v", err)
} else {
c.LogDebug("Configuration saved successfully")
}
return err
}
// Delete removes the configuration directory
func (c *Coolify) Delete() error {
configPath := path.Join(c.Config.Directory, "coolify")
c.LogDebug("Deleting configuration directory: %s", configPath)
err := os.RemoveAll(configPath)
if err != nil {
c.LogError("Failed to delete configuration directory: %v", err)
} else {
c.LogDebug("Configuration directory deleted successfully")
}
return err
}
// SetLogLevel sets the log level for the logger
func (c *Coolify) SetLogLevel(level string) {
switch level {
case "trace":
c.Logger.SetLevel(logrus.TraceLevel)
case "debug":
c.Logger.SetLevel(logrus.DebugLevel)
case "info":
c.Logger.SetLevel(logrus.InfoLevel)
case "warn", "warning":
c.Logger.SetLevel(logrus.WarnLevel)
case "error":
c.Logger.SetLevel(logrus.ErrorLevel)
case "fatal":
c.Logger.SetLevel(logrus.FatalLevel)
case "panic":
c.Logger.SetLevel(logrus.PanicLevel)
default:
c.Logger.SetLevel(logrus.InfoLevel)
}
}
// LogDebug logs a message at debug level
func (c *Coolify) LogDebug(format string, args ...interface{}) {
c.Logger.Debugf(format, args...)
}
// LogInfo logs a message at info level
func (c *Coolify) LogInfo(format string, args ...interface{}) {
c.Logger.Infof(format, args...)
}
// LogWarn logs a message at warn level
func (c *Coolify) LogWarn(format string, args ...interface{}) {
c.Logger.Warnf(format, args...)
}
// LogError logs a message at error level
func (c *Coolify) LogError(format string, args ...interface{}) {
c.Logger.Errorf(format, args...)
}
// LogTrace logs a message at trace level
func (c *Coolify) LogTrace(format string, args ...interface{}) {
c.Logger.Tracef(format, args...)
}
-243
View File
@@ -1,243 +0,0 @@
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"log"
"github.com/spf13/cobra"
)
var WithResources bool
type Resource struct {
ID int `json:"id"`
Uuid string `json:"uuid"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
}
type Resources struct {
Resources []Resource `json:"resources"`
}
type Server struct {
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
IP string `json:"ip"`
User string `json:"user"`
Port int `json:"port"`
Settings struct {
Reachable bool `json:"is_reachable"`
Usable bool `json:"is_usable"`
} `json:"settings"`
}
var serversCmd = &cobra.Command{
Use: "servers",
Short: "Server related commands",
}
var listServersCmd = &cobra.Command{
Use: "list",
Short: "List all servers",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "servers"
data, err := Fetch(baseUrl)
if err != nil {
log.Println(err)
return
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata []Server
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(w, "Uuid\tName\tIP Address\tUser\tPort\tReachable\tUsable")
for _, resource := range jsondata {
if ShowSensitive {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%t\t%t\n", resource.UUID, resource.Name, resource.IP, resource.User, resource.Port, resource.Settings.Reachable, resource.Settings.Usable)
} else {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%t\t%t\n", resource.UUID, resource.Name, SensitiveInformationOverlay, SensitiveInformationOverlay, SensitiveInformationOverlay, resource.Settings.Reachable, resource.Settings.Usable)
}
}
w.Flush()
fmt.Println("\nNote: Use -s to show sensitive information.")
},
}
var oneServerCmd = &cobra.Command{
Use: "get [uuid]",
Args: cobra.ExactArgs(1),
Short: "Get server details by uuid",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "servers/"
uuid := args[0]
var url = baseUrl + uuid
if WithResources {
url = baseUrl + uuid + "?resources=true"
}
data, err := Fetch(url)
if err != nil {
fmt.Println(err)
return
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
if WithResources {
var jsondata Resources
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(w, "Uuid\tName\tType\tStatus")
for _, resource := range jsondata.Resources {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t\n", resource.Uuid, resource.Name, resource.Type, resource.Status)
}
w.Flush()
} else {
var jsondata Server
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(w, "Uuid\tName\tIP Address\tUser\tPort\tReachable\tUsable")
if ShowSensitive {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%t\t%t\n", jsondata.UUID, jsondata.Name, jsondata.IP, jsondata.User, jsondata.Port, jsondata.Settings.Reachable, jsondata.Settings.Usable)
} else {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%t\t%t\n", jsondata.UUID, jsondata.Name, SensitiveInformationOverlay, SensitiveInformationOverlay, SensitiveInformationOverlay, jsondata.Settings.Reachable, jsondata.Settings.Usable)
}
w.Flush()
fmt.Println("\nNote: Use -s to show sensitive information.")
}
},
}
var removeServerCmd = &cobra.Command{
Use: "remove [uuid]",
Short: "Remove a server",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "servers/"
uuid := args[0]
response, err := Delete(baseUrl + uuid)
if err != nil {
fmt.Println(err)
return
}
msg := map[string]string{}
json.Unmarshal([]byte(response), &msg)
fmt.Println(msg["message"])
},
}
var addServerCmd = &cobra.Command{
Use: "add [name] [ip] [private_key_uuid]",
Short: "Add a server",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "servers"
name := args[0]
ip := args[1]
privateKeyUuid := args[2]
port, _ := cmd.Flags().GetInt("port")
user, _ := cmd.Flags().GetString("user")
validate, _ := cmd.Flags().GetBool("validate")
jsonData, err := json.Marshal(map[string]interface{}{
"name": name,
"ip": ip,
"port": port,
"user": user,
"private_key_uuid": privateKeyUuid,
"instant_validate": validate,
})
if err != nil {
fmt.Println(err)
return
}
response, err := Post(baseUrl, bytes.NewBuffer(jsonData))
if err != nil {
fmt.Println(err)
return
}
msg := map[string]string{}
json.Unmarshal([]byte(response), &msg)
if validate {
fmt.Println("Server added successfully with uuid " + msg["uuid"])
} else {
fmt.Println("Server added successfully with uuid " + msg["uuid"] + ". Server is not validated. Use 'servers validate " + msg["uuid"] + "' to validate the server.")
}
},
}
var validateServerCmd = &cobra.Command{
Use: "validate [uuid]",
Short: "Validate a server",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "servers/"
uuid := args[0]
var url = baseUrl + uuid + "/validate"
response, err := Fetch(url)
if err != nil {
fmt.Println(err)
return
}
msg := map[string]string{}
json.Unmarshal([]byte(response), &msg)
fmt.Println(msg["message"])
},
}
func init() {
oneServerCmd.Flags().BoolVarP(&WithResources, "resources", "", false, "With resources")
rootCmd.AddCommand(serversCmd)
serversCmd.AddCommand(listServersCmd)
serversCmd.AddCommand(oneServerCmd)
addServerCmd.Flags().IntP("port", "p", 22, "Port")
addServerCmd.Flags().StringP("user", "u", "root", "User")
addServerCmd.Flags().BoolP("validate", "", false, "Validate the server")
serversCmd.AddCommand(addServerCmd)
serversCmd.AddCommand(validateServerCmd)
serversCmd.AddCommand(removeServerCmd)
}
-57
View File
@@ -1,57 +0,0 @@
package cmd
import (
"context"
"fmt"
"log"
"os"
"runtime"
selfupdate "github.com/creativeprojects/go-selfupdate"
compareVersion "github.com/hashicorp/go-version"
"github.com/spf13/cobra"
)
var updateCmd = &cobra.Command{
Use: "update",
Short: "Update Coolify CLI",
Run: func(cmd *cobra.Command, args []string) {
latest, found, err := selfupdate.DetectLatest(context.Background(), selfupdate.ParseSlug("coollabsio/coolify-cli"))
if err != nil {
log.Printf("Error occurred while detecting version: %v", err)
return
}
if !found {
log.Printf("Latest version for %s/%s could not be found from github repository", runtime.GOOS, runtime.GOARCH)
return
}
currentVersion, err := compareVersion.NewVersion(CliVersion)
if err != nil {
log.Printf("Could not parse current version: %v", err)
return
}
latestVersion, err := compareVersion.NewVersion(latest.Version())
if err != nil {
log.Printf("Could not parse latest version: %v", err)
return
}
if currentVersion.LessThan(latestVersion) {
exe, err := os.Executable()
if err != nil {
log.Printf("Could not locate executable path: %v", err)
return
}
if err := selfupdate.UpdateTo(context.Background(), latest.AssetURL, latest.AssetName, exe); err != nil {
fmt.Printf("Error occurred while updating binary: %v", err)
return
}
log.Printf("Successfully updated to version %s", latest.Version())
}
},
}
func init() {
rootCmd.AddCommand(updateCmd)
}
+23
View File
@@ -0,0 +1,23 @@
package utils
import (
"fmt"
"os"
"path/filepath"
)
// GetCommandExample generates example usage strings using the actual binary name
// rather than hardcoding it. This makes examples resistant to binary name changes.
func GetCommandExample(format string, args ...interface{}) string {
binaryName := getBinaryName()
return fmt.Sprintf(format, append([]interface{}{binaryName}, args...)...)
}
// getBinaryName returns the name of the current executable without path
func getBinaryName() string {
exe, err := os.Executable()
if err != nil {
return "coolify-cli" // Fallback to the default name
}
return filepath.Base(exe)
}
+3
View File
@@ -0,0 +1,3 @@
package utils
// Other utility functions can be added here
-19
View File
@@ -1,19 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Current Coolify CLI version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println(CliVersion)
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}
+57 -33
View File
@@ -1,49 +1,73 @@
module github.com/coollabsio/coolify-cli
module github.com/coollabsio/cli-coolify
go 1.22.0
toolchain go1.23.2
go 1.24.1
require (
github.com/adrg/xdg v0.5.3
github.com/creativeprojects/go-selfupdate v1.4.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.4
github.com/charmbracelet/lipgloss v1.1.0
github.com/google/go-github/v71 v71.0.0
github.com/hashicorp/go-version v1.7.0
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
github.com/oapi-codegen/runtime v1.1.1
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
golang.org/x/crypto v0.37.0
golang.org/x/oauth2 v0.29.0
golang.org/x/sys v0.32.0
)
require (
code.gitea.io/sdk/gitea v0.20.0 // indirect
github.com/42wim/httpsig v1.2.2 // indirect
github.com/Masterminds/semver/v3 v3.3.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.3.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/getkin/kin-openapi v0.127.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.9 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/invopop/yaml v0.3.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/xanzy/go-gitlab v0.115.0 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.9.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/tools v0.32.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
+232 -83
View File
@@ -1,127 +1,276 @@
code.gitea.io/sdk/gitea v0.20.0 h1:Zm/QDwwZK1awoM4AxdjeAQbxolzx2rIP8dDfmKu+KoU=
code.gitea.io/sdk/gitea v0.20.0/go.mod h1:faouBHC/zyx5wLgjmRKR62ydyvMzwWf3QnU0bH7Cw6U=
github.com/42wim/httpsig v1.2.2 h1:ofAYoHUNs/MJOLqQ8hIxeyz2QxOz8qdSVvp3PX/oPgA=
github.com/42wim/httpsig v1.2.2/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY=
github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creativeprojects/go-selfupdate v1.4.0 h1:4ePPd2CPCNl/YoPXeVxpuBLDUZh8rMEKP5ac+1Y/r5c=
github.com/creativeprojects/go-selfupdate v1.4.0/go.mod h1:oPG7LmzEmS6OxfqEm620k5VKxP45xFZNKMkp4V5qqUY=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ=
github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/getkin/kin-openapi v0.127.0 h1:Mghqi3Dhryf3F8vR370nN67pAERW+3a95vomb3MAREY=
github.com/getkin/kin-openapi v0.127.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30=
github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8=
github.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+6 -2
View File
@@ -1,9 +1,13 @@
package main
import (
"github.com/coollabsio/coolify-cli/cmd"
"os"
"github.com/coollabsio/cli-coolify/cmd"
)
func main() {
cmd.Execute()
if command, err := cmd.NewCliRoot().NewCommand(); err != nil || command.Execute() != nil {
os.Exit(1)
}
}
+21
View File
@@ -0,0 +1,21 @@
# OpenAPI Code Generation
This directory contains code generated from the Coolify OpenAPI specification.
## Overview
The code in this directory is automatically generated using [oapi-codegen](https://github.com/oapi-codegen/oapi-codegen) from the OpenAPI spec located at `/openapi.yaml`. Do not modify any generated files manually as changes will be overwritten.
## Generation Process
The code generation is configured via:
1. `generate.go` - Contains the go:generate directive to run oapi-codegen
2. `oapi-codegen.yaml` - Contains the configuration for oapi-codegen
3. `overlay.yaml` - Contains an overlay specification to make modifications to the yaml spec before generating
To regenerate the code, run:
```
go generate ./...
```
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
package openapi
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest -config oapi-codegen.yaml ../../../openapi.yaml
// We need to implement methods on some structs we do this here so the generated code doesnt break
func (s *Server) GetFilterValue() string {
return *s.Name
}
func (p *PrivateKey) GetFilterValue() string {
return *p.Name
}
+10
View File
@@ -0,0 +1,10 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/HEAD/configuration-schema.json
package: openapi
output: client.gen.go
generate:
models: true
client: true
output-options:
overlay:
path: overlay.yaml
strict: true
+10
View File
@@ -0,0 +1,10 @@
overlay: 1.0.0
info:
title: Remove UUID Format Overlay
version: 1.0.0
actions:
- target: "$..[?(@.format == 'uuid')]"
description: "Remove all format: uuid fields"
update:
format: null
+470
View File
@@ -0,0 +1,470 @@
package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Message types for success/error notifications
type clearMessageMsg struct{}
// Duration for how long to show messages
const messageTimeout = 5 * time.Second
// FilterableItem represents an item that can be filtered by name
type FilterableItem interface {
GetFilterValue() string
}
// KeyMap defines keybindings for the table view
type KeyMap struct {
Up key.Binding
Down key.Binding
Sensitive key.Binding
Detail key.Binding
Delete key.Binding
Confirm key.Binding
Cancel key.Binding
Help key.Binding
Quit key.Binding
}
// ShortHelp returns keybindings to be shown in the mini help view
func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Quit}
}
// FullHelp returns keybindings for the expanded help view
func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down}, // first column
{k.Sensitive, k.Detail, k.Delete, k.Help}, // second column
{k.Quit}, // third column
}
}
// DefaultKeyMap returns the default key bindings
func DefaultKeyMap() KeyMap {
return KeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "move up"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "move down"),
),
Sensitive: key.NewBinding(
key.WithKeys("ctrl+s"),
key.WithHelp("ctrl+s", "toggle sensitive info"),
),
Detail: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "show details"),
),
Delete: key.NewBinding(
key.WithKeys("delete"),
key.WithHelp("delete", "delete item"),
),
Confirm: key.NewBinding(
key.WithKeys("y", "Y"),
key.WithHelp("y", "confirm"),
),
Cancel: key.NewBinding(
key.WithKeys("n", "N", "esc"),
key.WithHelp("n", "cancel"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys("ctrl+c"),
key.WithHelp("ctrl+c", "quit"),
),
}
}
// FilterableTable manages a filterable table view
type FilterableTable struct {
Table table.Model
FilterInput textinput.Model
Items []FilterableItem
Columns []table.Column
RowBuilder func(item FilterableItem) table.Row
DetailBuilder func(item FilterableItem, sensitive bool) string
DeleteHandler func(item FilterableItem) error
KeyMap KeyMap
Help help.Model
Viewport viewport.Model
ViewportReady bool
detailHeader string
ConfirmDeleteMode bool
DetailMode bool
Sensitive bool
Width int
Height int
messageTimer *time.Timer
Err error
SuccessMsg string
}
// New creates a new FilterableTable
func NewTableFilter(items []FilterableItem, columns []table.Column, rowBuilder func(item FilterableItem) table.Row) *FilterableTable {
t := table.New(
table.WithColumns(columns),
table.WithFocused(true),
table.WithStyles(table.Styles{
Selected: FocusedStyle,
}),
)
// Create the filter input
filterInput := NewFocusedInput("Filter by name", "Filter: ")
// Initialize viewport
vp := viewport.New(0, 0)
vp.Style = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Padding(0, 2)
ft := &FilterableTable{
Table: t,
FilterInput: filterInput,
Items: items,
Columns: columns,
RowBuilder: rowBuilder,
KeyMap: DefaultKeyMap(),
Help: help.New(),
Viewport: vp,
ViewportReady: true,
}
// Initialize table with filtered rows
ft.updateTableRows()
return ft
}
// WithInitialFilter sets the initial filter
func (ft *FilterableTable) WithInitialFilter(initialFilter string) *FilterableTable {
ft.FilterInput.SetValue(initialFilter)
return ft
}
// WithDetailView adds detail view support
func (ft *FilterableTable) WithDetailView(detailBuilder func(item FilterableItem, sensitive bool) string) *FilterableTable {
ft.DetailBuilder = detailBuilder
return ft
}
// WithDeleteHandler adds delete support
func (ft *FilterableTable) WithDeleteHandler(deleteHandler func(item FilterableItem) error) *FilterableTable {
ft.DeleteHandler = deleteHandler
return ft
}
// WithViewportHeader sets the header text for the detail view
func (ft *FilterableTable) WithDetailHeader(header string) *FilterableTable {
ft.detailHeader = header
return ft
}
// setError sets an error message and starts the clear timer
func (ft *FilterableTable) setError(err error) tea.Cmd {
ft.Err = err
ft.SuccessMsg = "" // Clear success when showing error
// Cancel existing timer if any
if ft.messageTimer != nil {
ft.messageTimer.Stop()
}
return tea.Tick(messageTimeout, func(t time.Time) tea.Msg {
return clearMessageMsg{}
})
}
// setSuccess sets a success message and starts the clear timer
func (ft *FilterableTable) setSuccess(msg string) tea.Cmd {
ft.SuccessMsg = msg
ft.Err = nil // Clear error when showing success
// Cancel existing timer if any
if ft.messageTimer != nil {
ft.messageTimer.Stop()
}
return tea.Tick(messageTimeout, func(t time.Time) tea.Msg {
return clearMessageMsg{}
})
}
func (ft *FilterableTable) Update(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
if _, ok := msg.(clearMessageMsg); ok {
ft.Err = nil
ft.SuccessMsg = ""
return nil
}
var cmd tea.Cmd
ft.FilterInput, cmd = ft.updateFilter(msg)
cmds = append(cmds, cmd)
ft.Table, cmd = ft.updateTable(msg)
cmds = append(cmds, cmd)
return tea.Batch(cmds...)
}
// UpdateFilter updates the filter and refreshes the table rows
func (ft *FilterableTable) updateFilter(msg tea.Msg) (textinput.Model, tea.Cmd) {
// Ignore help key in filter input
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if key.Matches(keyMsg, ft.KeyMap.Help) {
return ft.FilterInput, nil
}
// If in confirm delete mode, quit updating filter input from y/n inputs
if ft.ConfirmDeleteMode && (key.Matches(keyMsg, ft.KeyMap.Cancel) || key.Matches(keyMsg, ft.KeyMap.Confirm)) {
return ft.FilterInput, nil
}
}
prevFilter := ft.FilterInput.Value()
var cmd tea.Cmd
ft.FilterInput, cmd = ft.FilterInput.Update(msg)
// Update filtered rows when filter changes
if prevFilter != ft.FilterInput.Value() {
ft.updateTableRows()
}
return ft.FilterInput, cmd
}
// UpdateTable updates the table with the given message
func (ft *FilterableTable) updateTable(msg tea.Msg) (table.Model, tea.Cmd) {
var cmds []tea.Cmd
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch {
case key.Matches(keyMsg, ft.KeyMap.Detail):
if ft.ConfirmDeleteMode || ft.DetailMode {
return ft.Table, nil
}
if ft.DetailBuilder != nil {
// Update viewport content with current item's details
if item, ok := ft.GetSelectedItem(); ok {
ft.DetailMode = true
content := ft.DetailBuilder(item, ft.Sensitive)
ft.Viewport.SetContent(content)
// Reset viewport position
ft.Viewport.GotoTop()
}
return ft.Table, nil
}
case key.Matches(keyMsg, ft.KeyMap.Delete):
// We have a delete handler and we are not in confirm delete mode or detail mode
if ft.DeleteHandler != nil && !ft.ConfirmDeleteMode && !ft.DetailMode {
ft.ConfirmDeleteMode = true
return ft.Table, nil
}
case key.Matches(keyMsg, ft.KeyMap.Confirm):
if ft.ConfirmDeleteMode && ft.DeleteHandler != nil {
if item, ok := ft.GetSelectedItem(); ok {
if err := ft.DeleteHandler(item); err != nil {
cmds = append(cmds, ft.setError(err))
} else {
// Remove the item from the Items slice
for i, existing := range ft.Items {
if existing == item {
ft.Items = append(ft.Items[:i], ft.Items[i+1:]...)
break
}
}
// Update the table rows to reflect the deletion
ft.updateTableRows()
cmds = append(cmds, ft.setSuccess(fmt.Sprintf("Successfully deleted %s", item.GetFilterValue())))
}
}
ft.ConfirmDeleteMode = false
return ft.Table, tea.Batch(cmds...)
}
case key.Matches(keyMsg, ft.KeyMap.Cancel):
if ft.ConfirmDeleteMode {
ft.ConfirmDeleteMode = false
return ft.Table, nil
}
if ft.DetailMode {
ft.DetailMode = false
return ft.Table, nil
}
case key.Matches(keyMsg, ft.KeyMap.Sensitive):
ft.Sensitive = !ft.Sensitive
// Update viewport content if in detail mode
if ft.DetailMode {
if item, ok := ft.GetSelectedItem(); ok {
content := ft.DetailBuilder(item, ft.Sensitive)
ft.Viewport.SetContent(content)
}
}
return ft.Table, nil
case key.Matches(keyMsg, ft.KeyMap.Help):
ft.Help.ShowAll = !ft.Help.ShowAll
return ft.Table, nil
case key.Matches(keyMsg, ft.KeyMap.Quit):
return ft.Table, tea.Quit
}
}
// Handle window size updates
if msg, ok := msg.(tea.WindowSizeMsg); ok {
ft.Width = msg.Width
ft.Height = msg.Height
// Calculate viewport height accounting for header, footer, and help
viewportHeight := ft.Height - 4 // Base reduction for borders/help
if ft.detailHeader != "" {
viewportHeight -= 4 // Account for header and spacing
}
ft.Viewport.Width = msg.Width
ft.Viewport.Height = viewportHeight
// Update content to fit new size if in detail mode
if ft.DetailMode {
if item, ok := ft.GetSelectedItem(); ok {
content := ft.DetailBuilder(item, ft.Sensitive)
ft.Viewport.SetContent(content)
}
}
return ft.Table, nil
}
var cmd tea.Cmd
if ft.DetailMode {
// Update viewport when in detail mode
var viewportCmd tea.Cmd
ft.Viewport, viewportCmd = ft.Viewport.Update(msg)
return ft.Table, viewportCmd
}
ft.Table, cmd = ft.Table.Update(msg)
return ft.Table, cmd
}
// FilteredItems returns the currently filtered items
func (ft *FilterableTable) FilteredItems() []FilterableItem {
filter := strings.ToLower(ft.FilterInput.Value())
if filter == "" {
return ft.Items
}
var filtered []FilterableItem
for _, item := range ft.Items {
if strings.Contains(strings.ToLower(item.GetFilterValue()), filter) {
filtered = append(filtered, item)
}
}
return filtered
}
// GetSelectedItem returns the currently selected item
func (ft *FilterableTable) GetSelectedItem() (FilterableItem, bool) {
filtered := ft.FilteredItems()
if len(filtered) == 0 || ft.Table.Cursor() >= len(filtered) {
return nil, false
}
return filtered[ft.Table.Cursor()], true
}
// updateTableRows updates the table rows based on the current filter
func (ft *FilterableTable) updateTableRows() {
filtered := ft.FilteredItems()
rows := make([]table.Row, len(filtered))
for i, item := range filtered {
rows[i] = ft.RowBuilder(item)
}
ft.Table.SetRows(rows)
// Ensure cursor stays within bounds
switch length := len(filtered); {
case length == 0:
ft.Table.SetCursor(0)
case ft.Table.Cursor() >= length:
ft.Table.SetCursor(length - 1)
case ft.Table.Cursor() < 0:
ft.Table.SetCursor(0)
}
}
// View returns the combined view of filter input, table, and help
func (ft *FilterableTable) View() string {
var s strings.Builder
if ft.DetailMode && ft.DetailBuilder != nil {
if !ft.ViewportReady {
return "Loading..."
}
if ft.detailHeader != "" {
s.WriteString(FocusedStyle.Bold(true).Render(ft.detailHeader))
s.WriteString("\n\n")
}
s.WriteString(ft.Viewport.View())
footer := lipgloss.JoinHorizontal(
lipgloss.Center,
BlurredStyle.Render("↑/↓: scroll • ESC: back"),
strings.Repeat(" ", 3),
BlurredStyle.Render(fmt.Sprintf("%.f%%", ft.Viewport.ScrollPercent()*100)),
)
s.WriteString("\n" + footer)
s.WriteString("\n\n")
s.WriteString(ft.Help.View(ft.KeyMap))
return s.String()
}
if ft.ConfirmDeleteMode {
if item, ok := ft.GetSelectedItem(); ok {
confirmStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("204")).
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("204")).
Padding(0, 1)
confirmMsg := fmt.Sprintf("Delete %s? [y/N]", item.GetFilterValue())
return confirmStyle.Render(confirmMsg)
}
ft.ConfirmDeleteMode = false
}
s.WriteString(ft.FilterInput.View())
s.WriteString("\n\n")
s.WriteString(ft.Table.View())
s.WriteString("\n\n")
s.WriteString(ft.Help.View(ft.KeyMap))
s.WriteString("\n\n")
if ft.Err != nil {
s.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v\n", ft.Err)))
s.WriteString("\n")
}
if ft.SuccessMsg != "" {
s.WriteString(SuccessStyle.Render(ft.SuccessMsg))
s.WriteString("\n")
}
return s.String()
}
+46
View File
@@ -0,0 +1,46 @@
package tui
// TUI is the package for TUI components of Coolify cli.
import (
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss"
)
var (
FocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#6B2E85", // Darker purple for light theme
Dark: "#875FFF", // ANSI color 99
})
BlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#6B6B87", // Muted grayish-purple for light theme (similar to ANSI 60)
Dark: "#5F5F87", // ANSI color 60
})
ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#B22B31", // Darker red for light theme
Dark: "#FF5C5C", // Bright red for dark theme
})
SuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#2D5A27", // Darker green for light theme
Dark: "#4CAF50", // Bright green for dark theme
})
)
func NewTextInput(placeholder, prompt string, style lipgloss.Style) textinput.Model {
t := textinput.New()
t.Placeholder = placeholder
t.Prompt = prompt
t.PromptStyle = style
t.TextStyle = style
return t
}
func NewFocusedInput(placeholder, prompt string) textinput.Model {
t := NewTextInput(placeholder, prompt, FocusedStyle)
t.Focus()
return t
}
func NewBlurredInput(placeholder, prompt string) textinput.Model {
return NewTextInput(placeholder, prompt, BlurredStyle)
}
+43
View File
@@ -0,0 +1,43 @@
package tui
import (
"errors"
"net/url"
"strings"
)
var (
ErrValueEmpty = errors.New("value cannot be empty")
ErrFqdnInvalid = errors.New("fqdn must contain a scheme (http:// or https://)")
ErrFqdnHostMissing = errors.New("fqdn must contain a host")
)
// ValidateNotEmpty validates that a string is not empty even if it has whitespace
func ValidateNotEmpty(s string) error {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return ErrValueEmpty
}
return nil
}
// ValidateFQDN validates that a string is a valid FQDN with scheme and host
func ValidateFQDN(s string) error {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return ErrValueEmpty
}
if !strings.Contains(trimmed, "://") {
return ErrFqdnInvalid
}
u, err := url.Parse(trimmed)
if err != nil {
return err
}
if u.Host == "" {
return ErrFqdnHostMissing
}
return nil
}
+98
View File
@@ -0,0 +1,98 @@
package updater
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
)
// ExtractTarGz extracts a tar.gz archive to a temporary directory
// and returns the path to the binary
func ExtractTarGz(archiveFile io.Reader, binaryName string) (string, error) {
// Create a temporary directory to extract files
tempDir, err := os.MkdirTemp("", "coolify-update")
if err != nil {
return "", fmt.Errorf("failed to create temp dir: %w", err)
}
// Extract the tar.gz file
gzipReader, err := gzip.NewReader(archiveFile)
if err != nil {
if rmErr := os.RemoveAll(tempDir); rmErr != nil {
return "", fmt.Errorf("failed to create gzip reader: %w (cleanup failed: %v)", err, rmErr)
}
return "", fmt.Errorf("failed to create gzip reader: %w", err)
}
defer func() {
if closeErr := gzipReader.Close(); closeErr != nil {
fmt.Fprintf(os.Stderr, "Error closing gzip reader: %v\n", closeErr)
}
}()
tarReader := tar.NewReader(gzipReader)
// Extract binary from the archive
binaryPath := ""
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
if rmErr := os.RemoveAll(tempDir); rmErr != nil {
return "", fmt.Errorf("error reading tar: %w (cleanup failed: %v)", err, rmErr)
}
return "", fmt.Errorf("error reading tar: %w", err)
}
// Skip directories and non-binary files
if header.Typeflag != tar.TypeReg {
continue
}
// Check if this is the binary we're looking for
baseName := filepath.Base(header.Name)
if baseName == binaryName {
// Create the output file
outPath := filepath.Join(tempDir, baseName)
outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_RDWR, 0o755)
if err != nil {
if rmErr := os.RemoveAll(tempDir); rmErr != nil {
return "", fmt.Errorf("failed to create output file: %w (cleanup failed: %v)", err, rmErr)
}
return "", fmt.Errorf("failed to create output file: %w", err)
}
// Copy the file contents
if _, err := io.Copy(outFile, tarReader); err != nil {
if closeErr := outFile.Close(); closeErr != nil {
fmt.Fprintf(os.Stderr, "Error closing output file: %v\n", closeErr)
}
if rmErr := os.RemoveAll(tempDir); rmErr != nil {
return "", fmt.Errorf("failed to extract file: %w (cleanup failed: %v)", err, rmErr)
}
return "", fmt.Errorf("failed to extract file: %w", err)
}
if closeErr := outFile.Close(); closeErr != nil {
if rmErr := os.RemoveAll(tempDir); rmErr != nil {
return "", fmt.Errorf("failed to close output file: %w (cleanup failed: %v)", closeErr, rmErr)
}
return "", fmt.Errorf("failed to close output file: %w", closeErr)
}
binaryPath = outPath
}
}
if binaryPath == "" {
if rmErr := os.RemoveAll(tempDir); rmErr != nil {
return "", fmt.Errorf("binary %s not found in archive (cleanup failed: %v)", binaryName, rmErr)
}
return "", fmt.Errorf("binary %s not found in archive", binaryName)
}
return binaryPath, nil
}
+223
View File
@@ -0,0 +1,223 @@
package updater
import (
"context"
"fmt"
"io"
"net/http"
"os"
"runtime"
"strings"
"time"
"github.com/google/go-github/v71/github"
"github.com/hashicorp/go-version"
"golang.org/x/oauth2"
)
const (
DownloadTimeout = 5 * time.Minute
BinaryName = "coolify"
)
// ReleaseInfo contains information about a GitHub release
type ReleaseInfo struct {
Version string
AssetURL string
AssetName string
ChecksumURL string
PublishedDate time.Time
PreRelease bool
Notes string
}
// GithubUpdater handles interaction with GitHub releases
type GithubUpdater struct {
client *github.Client
httpClient *http.Client
owner string
repo string
binaryName string
currentVersion string
}
// NewGithubUpdater creates a new GitHub updater with appropriate configuration
func NewGithubUpdater(owner, repo, currentVersion string) *GithubUpdater {
httpClient := &http.Client{
Timeout: DownloadTimeout,
}
// Use GitHub token if available for better rate limits
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
httpClient = oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}))
}
return &GithubUpdater{
client: github.NewClient(httpClient),
httpClient: httpClient,
owner: owner,
repo: repo,
binaryName: BinaryName,
currentVersion: currentVersion,
}
}
// DetectLatest finds the latest available release
func (g *GithubUpdater) DetectLatest(ctx context.Context, includePrerelease bool) (*ReleaseInfo, error) {
var release *github.RepositoryRelease
var err error
if includePrerelease {
// List all releases including pre-releases
releases, _, err := g.client.Repositories.ListReleases(ctx, g.owner, g.repo, &github.ListOptions{PerPage: 10})
if err != nil {
return nil, fmt.Errorf("error listing releases: %w", err)
}
if len(releases) == 0 {
return nil, fmt.Errorf("no releases found for %s/%s", g.owner, g.repo)
}
release = releases[0]
} else {
// Get only the latest stable release
release, _, err = g.client.Repositories.GetLatestRelease(ctx, g.owner, g.repo)
if err != nil {
return nil, fmt.Errorf("error getting latest release: %w", err)
}
}
if release == nil {
return nil, fmt.Errorf("no release found for %s/%s", g.owner, g.repo)
}
assetName, assetURL, err := g.findMatchingAsset(release)
if err != nil {
return nil, err
}
checksumURL := ""
for _, asset := range release.Assets {
if strings.Contains(asset.GetName(), "checksums.txt") {
checksumURL = asset.GetBrowserDownloadURL()
break
}
}
versionStr := strings.TrimPrefix(release.GetTagName(), "v")
return &ReleaseInfo{
Version: versionStr,
AssetURL: assetURL,
AssetName: assetName,
ChecksumURL: checksumURL,
PublishedDate: release.GetPublishedAt().Time,
PreRelease: release.GetPrerelease(),
Notes: release.GetBody(),
}, nil
}
// findMatchingAsset finds the appropriate release asset for the current platform
func (g *GithubUpdater) findMatchingAsset(release *github.RepositoryRelease) (assetName, assetURL string, err error) {
// Construct the asset name pattern based on current platform
platform := runtime.GOOS
arch := runtime.GOARCH
// Based on goreleaser template from .goreleaser.yml
assetNamePattern := fmt.Sprintf("%s_%s_%s_%s.tar.gz", g.binaryName,
strings.TrimPrefix(release.GetTagName(), "v"), platform, arch)
for _, asset := range release.Assets {
if asset.GetName() == assetNamePattern {
assetName = asset.GetName()
assetURL = asset.GetBrowserDownloadURL()
return assetName, assetURL, nil
}
}
return "", "", fmt.Errorf("no matching asset found for %s/%s", platform, arch)
}
// DownloadAsset downloads an asset from GitHub
func (g *GithubUpdater) DownloadAsset(ctx context.Context, url string) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
resp, err := g.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("error downloading asset: %w", err)
}
if resp.StatusCode != http.StatusOK {
if closeErr := resp.Body.Close(); closeErr != nil {
return nil, fmt.Errorf("unexpected status code: %d (failed to close response: %v)", resp.StatusCode, closeErr)
}
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return resp.Body, nil
}
// CheckForUpdate checks if a newer version is available
func (g *GithubUpdater) CheckForUpdate(ctx context.Context, includePrerelease bool) (*ReleaseInfo, bool, error) {
latest, err := g.DetectLatest(ctx, includePrerelease)
if err != nil {
return nil, false, err
}
currentVer, err := version.NewVersion(g.currentVersion)
if err != nil {
return nil, false, fmt.Errorf("invalid current version: %w", err)
}
latestVer, err := version.NewVersion(latest.Version)
if err != nil {
return nil, false, fmt.Errorf("invalid latest version: %w", err)
}
return latest, currentVer.LessThan(latestVer), nil
}
// DownloadChecksums downloads the checksums file
func (g *GithubUpdater) DownloadChecksums(ctx context.Context, url string) (map[string]string, error) {
if url == "" {
return nil, fmt.Errorf("no checksums URL provided")
}
body, err := g.DownloadAsset(ctx, url)
if err != nil {
return nil, err
}
defer func() {
if closeErr := body.Close(); closeErr != nil {
fmt.Fprintf(os.Stderr, "Error closing checksum response: %v\n", closeErr)
}
}()
data, err := io.ReadAll(body)
if err != nil {
return nil, fmt.Errorf("error reading checksums: %w", err)
}
checksums := make(map[string]string)
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) != 2 {
continue
}
checksum := parts[0]
filename := parts[1]
checksums[filename] = checksum
}
return checksums, nil
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !windows
// +build !windows
package updater
import (
"fmt"
)
// hideWindowsFile is a no-op on non-Windows systems
func hideWindowsFile(path string) error {
// For non-Windows systems, we don't need to do anything special
fmt.Printf("Note: Old executable backup at %s\n", path)
return nil
}
+36
View File
@@ -0,0 +1,36 @@
//go:build windows
// +build windows
package updater
import (
"fmt"
"golang.org/x/sys/windows"
)
// hideWindowsFile sets the hidden attribute on a Windows file
func hideWindowsFile(path string) error {
// Convert to UTF16 for Windows API
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return fmt.Errorf("error converting path to UTF16: %w", err)
}
// Get current attributes
attrs, err := windows.GetFileAttributes(pathPtr)
if err != nil {
return fmt.Errorf("error getting file attributes: %w", err)
}
// Add hidden attribute
attrs |= windows.FILE_ATTRIBUTE_HIDDEN
// Set new attributes
err = windows.SetFileAttributes(pathPtr, attrs)
if err != nil {
return fmt.Errorf("error setting file attributes: %w", err)
}
return nil
}
+151
View File
@@ -0,0 +1,151 @@
package updater
import (
"fmt"
"io"
"os"
"path/filepath"
"runtime"
)
// Replace replaces the current executable with the new version
func Replace(newBinaryPath, executablePath string) error {
// Get executable path if not provided
if executablePath == "" {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("error getting executable path: %w", err)
}
executablePath = exe
}
// Different replacement strategies based on OS
if runtime.GOOS == "windows" {
return replaceWindowsExecutable(newBinaryPath, executablePath)
}
return replaceUnixExecutable(newBinaryPath, executablePath)
}
// replaceUnixExecutable replaces the executable on Unix systems
func replaceUnixExecutable(newBinaryPath, executablePath string) error {
// Open the new binary file
newFile, err := os.Open(newBinaryPath)
if err != nil {
return fmt.Errorf("error opening new binary: %w", err)
}
defer func() {
if closeErr := newFile.Close(); closeErr != nil {
fmt.Fprintf(os.Stderr, "Error closing new binary file: %v\n", closeErr)
}
}()
// Create a temporary file in the same directory
dir := filepath.Dir(executablePath)
tempFile, err := os.CreateTemp(dir, "coolify-update-*")
if err != nil {
return fmt.Errorf("error creating temp file: %w", err)
}
tempPath := tempFile.Name()
// Copy the new binary to the temporary file
if _, err := io.Copy(tempFile, newFile); err != nil {
if closeErr := tempFile.Close(); closeErr != nil {
fmt.Fprintf(os.Stderr, "Error closing temp file: %v\n", closeErr)
}
if rmErr := os.Remove(tempPath); rmErr != nil {
return fmt.Errorf("error copying new binary: %w (cleanup failed: %v)", err, rmErr)
}
return fmt.Errorf("error copying new binary: %w", err)
}
if closeErr := tempFile.Close(); closeErr != nil {
if rmErr := os.Remove(tempPath); rmErr != nil {
return fmt.Errorf("error closing temp file: %w (cleanup failed: %v)", closeErr, rmErr)
}
return fmt.Errorf("error closing temp file: %w", closeErr)
}
// Set permissions to match the current executable
info, err := os.Stat(executablePath)
if err != nil {
if rmErr := os.Remove(tempPath); rmErr != nil {
return fmt.Errorf("error getting executable info: %w (cleanup failed: %v)", err, rmErr)
}
return fmt.Errorf("error getting executable info: %w", err)
}
if err := os.Chmod(tempPath, info.Mode()); err != nil {
if rmErr := os.Remove(tempPath); rmErr != nil {
return fmt.Errorf("error setting permissions: %w (cleanup failed: %v)", err, rmErr)
}
return fmt.Errorf("error setting permissions: %w", err)
}
// Rename the temporary file to the executable path
if err := os.Rename(tempPath, executablePath); err != nil {
if rmErr := os.Remove(tempPath); rmErr != nil {
return fmt.Errorf("error replacing executable: %w (cleanup failed: %v)", err, rmErr)
}
return fmt.Errorf("error replacing executable: %w", err)
}
return nil
}
// replaceWindowsExecutable replaces the executable on Windows
func replaceWindowsExecutable(newBinaryPath, executablePath string) error {
// Define paths
dir := filepath.Dir(executablePath)
base := filepath.Base(executablePath)
oldPath := filepath.Join(dir, "."+base+".old")
// Step 1: Rename the target to .target.old
// If an old backup exists from a previous update, try to remove it first
_ = os.Remove(oldPath) // Ignore errors if file doesn't exist
if err := os.Rename(executablePath, oldPath); err != nil {
return fmt.Errorf("error renaming executable to backup: %w", err)
}
// Step 2: Rename the new binary (.target.new) to target
if err := os.Rename(newBinaryPath, executablePath); err != nil {
// Step 4: If rename fails, attempt to roll back
rollbackErr := os.Rename(oldPath, executablePath)
if rollbackErr != nil {
return fmt.Errorf("failed to replace executable (%w) and rollback also failed (%v)", err, rollbackErr)
}
return fmt.Errorf("error replacing executable (rollback successful): %w", err)
}
// Step 3: On Windows, we can't easily delete the old file,
// so instead we just make it a hidden file
// The actual implementation is in hidewindows_windows.go for Windows
// and hidewindows_other.go for non-Windows platforms
if err := hideWindowsFile(oldPath); err != nil {
// Non-fatal error - log but don't fail the update
fmt.Fprintf(os.Stderr, "Warning: Failed to hide old executable: %v\n", err)
}
return nil
}
// startProcess starts a new process and returns immediately
func startProcess(command string, args []string) error {
attr := &os.ProcAttr{
Dir: filepath.Dir(command),
Env: os.Environ(),
Files: []*os.File{nil, nil, nil},
}
process, err := os.StartProcess(command, append([]string{command}, args...), attr)
if err != nil {
return err
}
// Detach from the process
if releaseErr := process.Release(); releaseErr != nil {
return fmt.Errorf("failed to release process: %w", releaseErr)
}
return nil
}
+97
View File
@@ -0,0 +1,97 @@
package updater
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
)
// Updater is the main interface for updating the CLI
type Updater struct {
githubUpdater *GithubUpdater
owner string
repo string
binaryName string
}
// New creates a new updater instance
func New(owner, repo, currentVersion string) *Updater {
return &Updater{
githubUpdater: NewGithubUpdater(owner, repo, currentVersion),
owner: owner,
repo: repo,
binaryName: "coolify",
}
}
// Check checks if there's a newer version available without performing an update
func (u *Updater) Check(ctx context.Context, includePrerelease bool) (*ReleaseInfo, bool, error) {
return u.githubUpdater.CheckForUpdate(ctx, includePrerelease)
}
// To updates the CLI to release version passed in
func (u *Updater) To(ctx context.Context, release *ReleaseInfo) (string, error) {
// Download the asset
assetReader, err := u.githubUpdater.DownloadAsset(ctx, release.AssetURL)
if err != nil {
return "", fmt.Errorf("error downloading asset: %w", err)
}
defer func() {
if closeErr := assetReader.Close(); closeErr != nil {
fmt.Fprintf(os.Stderr, "Error closing asset reader: %v\n", closeErr)
}
}()
// Read the asset data once so we can verify and extract
assetData, err := io.ReadAll(assetReader)
if err != nil {
return "", fmt.Errorf("error reading asset data: %w", err)
}
// Verify checksum if available
if release.ChecksumURL != "" {
checksums, err := u.githubUpdater.DownloadChecksums(ctx, release.ChecksumURL)
if err != nil {
return "", fmt.Errorf("error downloading checksums: %w", err)
}
expectedChecksum, ok := checksums[release.AssetName]
if ok {
// Verify the checksum
err = VerifyChecksumBytes(assetData, expectedChecksum)
if err != nil {
return "", fmt.Errorf("checksum verification failed: %w", err)
}
}
}
// Extract the binary
tempBinaryPath, err := ExtractTarGz(bytes.NewReader(assetData), u.binaryName)
if err != nil {
return "", fmt.Errorf("error extracting binary: %w", err)
}
// Clean up the temporary directory when we're done
tempDir := filepath.Dir(tempBinaryPath)
defer func() {
if rmErr := os.RemoveAll(tempDir); rmErr != nil {
fmt.Fprintf(os.Stderr, "Error removing temp directory: %v\n", rmErr)
}
}()
// Get the current executable path
executablePath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("error getting executable path: %w", err)
}
// Replace the binary
if err := Replace(tempBinaryPath, executablePath); err != nil {
return "", fmt.Errorf("error replacing binary: %w", err)
}
return release.Version, nil
}
+44
View File
@@ -0,0 +1,44 @@
package updater
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
)
// VerifyChecksum validates the integrity of the downloaded asset
func VerifyChecksum(reader io.Reader, expectedChecksum string) (io.Reader, error) {
// Read all bytes from the reader
data, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("error reading data: %w", err)
}
// Calculate the SHA256 checksum
hash := sha256.Sum256(data)
actualChecksum := hex.EncodeToString(hash[:])
// Compare checksums
if actualChecksum != expectedChecksum {
return nil, fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksum)
}
// Return a new reader with the same data
return bytes.NewReader(data), nil
}
// VerifyChecksumBytes validates the integrity of a byte slice
func VerifyChecksumBytes(data []byte, expectedChecksum string) error {
// Calculate the SHA256 checksum
hash := sha256.Sum256(data)
actualChecksum := hex.EncodeToString(hash[:])
// Compare checksums
if actualChecksum != expectedChecksum {
return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksum)
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
Vagrant.configure("2") do |config|
# Use Debian 12 (Bookworm) base box
config.vm.box = "debian/bookworm64"
# Configure VM settings for different providers
config.vm.provider "virtualbox" do |vb|
vb.memory = "4096" # 4GB RAM (Coolify requires at least 2GB)
vb.cpus = 2
vb.name = "coolify-test-box"
end
config.vm.provider "libvirt" do |libvirt|
libvirt.memory = "4096"
libvirt.cpus = 2
libvirt.default_prefix = "coolify"
end
# Common configuration for all providers
config.vm.hostname = "coolify-test"
# Network configuration
config.vm.network "private_network", type: "dhcp"
# Forward ports from VM to host loopback
config.vm.network "forwarded_port", guest: 8000, host: 8000, host_ip: "127.0.0.1" # Coolify default port
config.vm.network "forwarded_port", guest: 6001, host: 6001, host_ip: "127.0.0.1" # Coolify default port
config.vm.network "forwarded_port", guest: 6002, host: 6002, host_ip: "127.0.0.1" # Coolify default port
# Sync the current directory to /vagrant in the VM
config.vm.synced_folder ".", "/vagrant"
# Run the initialization script
config.vm.provision "shell", path: "vagrant-init.sh"
end
+5 -3
View File
@@ -1,5 +1,7 @@
#!/bin/bash
# This script installs the coolify-cli to /usr/local/bin/coolify from Github release
# Exit on error
set -e
args=("$@")
custom_version=${args[0]}
@@ -37,7 +39,7 @@ download_from_github() {
local release=$2
local name=$3
local filename=${name}_${release}_${OS}_${ARCH}.tar.gz
# https://github.com/coollabsio/coolify-cli/releases/download/0.0.1/coolify-cli_0.0.1_linux_amd64.tar.gz
# https://github.com/coollabsio/cli-coolify/releases/download/0.0.1/coolify_0.0.1_linux_amd64.tar.gz
# Construct download URL
local download_url="https://github.com/${repo}/releases/download/${release}/${filename}"
@@ -63,4 +65,4 @@ download_from_github() {
}
detect_platform
download_from_github "coollabsio/coolify-cli" $custom_version "coolify-cli"
download_from_github "coollabsio/cli-coolify" $custom_version "coolify"
+89
View File
@@ -0,0 +1,89 @@
#!/bin/bash
# Exit on error
set -e
## Create a function to generate a random password
function generate_password() {
# Generate a cryptographically secure random password
# - 12 characters long
# - Contains at least one lowercase, one uppercase, one digit, and one special character
# - Uses /dev/urandom for cryptographically secure randomness
# Define character sets
local lower="abcdefghijklmnopqrstuvwxyz"
local upper="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
local digits="0123456789"
local special="!@#%^&*_-"
# Function to get a random character from a string using /dev/urandom
function get_random_char() {
local chars=$1
local size=${#chars}
local index=$(od -An -N2 -i /dev/urandom | tr -d ' ' | awk "{print \$1 % ${size}}")
echo "${chars:${index}:1}"
}
# Initialize password with required characters
local password=""
password+="$(get_random_char "${lower}")" # Ensure one lowercase
password+="$(get_random_char "${upper}")" # Ensure one uppercase
password+="$(get_random_char "${digits}")" # Ensure one digit
password+="$(get_random_char "${special}")" # Ensure one special char
# Add remaining characters
local all_chars="${lower}${upper}${digits}${special}"
for ((i=0; i<8; i++)); do
password+="$(get_random_char "${all_chars}")"
done
# Shuffle the password using cryptographically secure randomness
local shuffled=""
local temp="$password"
while [ ${#temp} -gt 0 ]; do
local pos=$(od -An -N2 -i /dev/urandom | tr -d ' ' | awk "{print \$1 % ${#temp}}")
shuffled+="${temp:$pos:1}"
temp="${temp:0:$pos}${temp:$((pos+1))}"
done
echo "$shuffled"
}
# Update package lists and upgrade system
echo "Updating system packages..."
apt-get update
apt-get upgrade -y
# Install required dependencies
echo "Installing dependencies..."
apt-get install -y \
curl \
wget \
git \
vim \
htop
## Install Coolify
echo "Installing Coolify..."
## Generate a random password
PASSWORD=$(generate_password)
EMAIL="hi@coollabs.io"
curl -fsSL https://cdn.coollabs.io/coolify/install.sh > install.sh
env ROOT_USERNAME=test ROOT_USER_EMAIL=''"$EMAIL"'' ROOT_USER_PASSWORD=''"$PASSWORD"'' bash install.sh
# Add current user to docker group
usermod -aG docker vagrant
# Print completion message
echo "Installation complete!"
echo "Coolify should be accessible at http://localhost:8000"
echo "You can SSH into the VM using: vagrant ssh"
echo "Credentials:"
echo "Email: $EMAIL"
echo "Password: $PASSWORD"
## output crednetials inside the box
echo "Email: $EMAIL" >> /home/vagrant/credentials.txt
echo "Password: $PASSWORD" >> /home/vagrant/credentials.txt