forked from mirror/coolify-cli
Compare commits
27 Commits
v4.x
...
chore/refactor
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a128de992 | |||
| 6495804344 | |||
| 3a994cb19e | |||
| 9b8992d177 | |||
| de6418e532 | |||
| 5e8c823637 | |||
| 7c370540e2 | |||
| 35f152b3d1 | |||
| 2b8a3bd120 | |||
| 77a61d614e | |||
| 255b918d02 | |||
| 200313c1b8 | |||
| dd0d46b0fc | |||
| 7c6a6b4292 | |||
| ef4a847f10 | |||
| b22f7b6943 | |||
| 9a4ef0d6ac | |||
| 98a624af27 | |||
| cb185da557 | |||
| d809990bec | |||
| f66c4f4217 | |||
| decc3e092a | |||
| 611b14d2ea | |||
| d22e6607a9 | |||
| 1126defb7c | |||
| b4148d6344 | |||
| 8c38a5447a |
+38
-25
@@ -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
@@ -1,4 +1,9 @@
|
||||
coolify-cli
|
||||
cli-coolify
|
||||
coolify
|
||||
cli
|
||||
config.json
|
||||
config.json
|
||||
dist
|
||||
.vagrant
|
||||
.test
|
||||
|
||||
|
||||
@@ -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
@@ -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 }}"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package emoji
|
||||
|
||||
const (
|
||||
CheckMarkButton = "\u2705" // ✅
|
||||
CrossMark = "\u274c" // ❌
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package utils
|
||||
|
||||
// Other utility functions can be added here
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+37
@@ -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
@@ -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"
|
||||
|
||||
Executable
+89
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user