Initial commit created by kmanage
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
# kmanage
|
||||
|
||||
A fast, lightweight, and secure Go CLI tool for managing **Komodo Core Stacks** and **Git Repositories / Webhooks** with native **Multi-Account support** (Gitea, Forgejo, GitHub) and **OS Keyring encryption** (macOS Keychain, Windows Credential Manager, Linux SecretService).
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Security Concept (OS Keyring)](#security-concept-os-keyring)
|
||||
- [Installation & Build](#installation--build)
|
||||
- [Configuration](#configuration)
|
||||
- [1. Configure Komodo](#1-configure-komodo)
|
||||
- [2. Configure Git Accounts (Multi-Account)](#2-configure-git-accounts-multi-account)
|
||||
- [3. Manage Git Accounts](#3-manage-git-accounts)
|
||||
- [Command Reference](#command-reference)
|
||||
- [Komodo (`kmanage kmd`)](#komodo-kmanage-kmd)
|
||||
- [Git Repositories (`kmanage git`)](#git-repositories-kmanage-git)
|
||||
- [Git Webhooks (`kmanage git webhook`)](#git-webhooks-kmanage-git-webhook)
|
||||
- [PR Branch Auto-Deletion (`kmanage git pr-autodel`)](#pr-branch-auto-deletion-kmanage-git-pr-autodel)
|
||||
- [Global Options](#global-options)
|
||||
- [Shell Auto-Completion](#shell-auto-completion)
|
||||
- [Project Structure](#project-structure)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **Zero Plaintext Secrets**: Sensitive API keys and tokens are automatically stored encrypted in your operating system's native **OS Keyring** (Apple Keychain, Windows Credential Manager, Linux SecretService).
|
||||
- 👥 **Multi-Account & Multi-Provider**: Manage multiple Git servers and accounts simultaneously (e.g. self-hosted Gitea + GitHub).
|
||||
- 🚀 **Komodo Core API Integration**: Instant overview of stacks, servers, deploy status, linked Git providers, and webhook configurations.
|
||||
- 🐙 **Git Provider Integration (Gitea / Forgejo / GitHub)**:
|
||||
- List repositories with visibility, default branch, PR branch deletion status, and active webhooks.
|
||||
- **Webhook Management**: List webhooks by repo, delete by ID or URL, and run **server-wide prune** across one or all accounts.
|
||||
- **PR Branch Auto-Clean**: Enable or disable automatic branch deletion on PR merge (per repo, per owner/organization, or globally).
|
||||
- ⚡ **High Concurrency & Performance**: Parallel API calls powered by goroutines with controlled worker pools.
|
||||
- 📐 **Dynamic Table Layout**: Automatic column alignment via Go's `tabwriter` and full support for `--no-header`.
|
||||
- 🔍 **Dynamic Shell Auto-Completion**: Instant `<TAB>` completion for commands, flags, accounts, owner names, and repository names (backed by a 3-minute local cache).
|
||||
|
||||
---
|
||||
|
||||
## Security Concept (OS Keyring)
|
||||
|
||||
`kmanage` separates non-sensitive configuration metadata from sensitive credentials:
|
||||
|
||||
1. **Non-Sensitive Metadata**: Stored in `~/.kmanage.yaml` (URLs, account names, default account).
|
||||
2. **Sensitive Secrets** (`komodo_key`, `komodo_secret`, `git_token`): Stored in the encrypted **OS Keyring**.
|
||||
3. **Runtime Resolution Order**:
|
||||
- `1.` Direct environment variables (e.g. `KMANAGE_KOMODO_KEY`, `KMANAGE_GIT_GITEA_TOKEN`)
|
||||
- `2.` Native OS Keyring (macOS Keychain / Windows Credential Manager / Linux SecretService)
|
||||
- `3.` Local config file `~/.kmanage.yaml` (fallback for headless servers/CI)
|
||||
|
||||
---
|
||||
|
||||
## Installation & Build
|
||||
|
||||
### Prerequisites
|
||||
- [Go 1.22+](https://golang.org/dl/)
|
||||
|
||||
### Build
|
||||
```bash
|
||||
# Clone repository & compile binary
|
||||
go build -o kmanage main.go
|
||||
|
||||
# Optional: move binary to PATH
|
||||
sudo mv kmanage /usr/local/bin/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Configure Komodo
|
||||
```bash
|
||||
kmanage kmd configure --url https://kmd.example.com --key <API_KEY> --secret <API_SECRET>
|
||||
```
|
||||
|
||||
### 2. Configure Git Accounts (Multi-Account)
|
||||
|
||||
Add as many Git accounts as you need. The first added account is automatically set as the default.
|
||||
|
||||
```bash
|
||||
# Add a Gitea / Forgejo account:
|
||||
kmanage git configure --name gitea --url https://git.example.com --token <GITEA_ACCESS_TOKEN>
|
||||
|
||||
# Add a GitHub account:
|
||||
kmanage git configure --name github --type github --token <GITHUB_PERSONAL_ACCESS_TOKEN>
|
||||
|
||||
# Add an additional work or organization account:
|
||||
kmanage git configure --name work --url https://git.company.com --token <TOKEN>
|
||||
```
|
||||
|
||||
### 3. Manage Git Accounts
|
||||
|
||||
```bash
|
||||
# List all configured accounts:
|
||||
kmanage git accounts
|
||||
|
||||
# Switch default account:
|
||||
kmanage git set-default github
|
||||
|
||||
# Delete account (also removes token from OS keyring):
|
||||
kmanage git delete-account work
|
||||
```
|
||||
|
||||
### Example `~/.kmanage.yaml`
|
||||
```yaml
|
||||
komodo_url: https://kmd.example.com
|
||||
default_git_account: gitea
|
||||
git_accounts:
|
||||
gitea:
|
||||
type: gitea
|
||||
url: https://git.example.com
|
||||
github:
|
||||
type: github
|
||||
url: https://api.github.com
|
||||
```
|
||||
*(All tokens and secret keys are stored securely in the OS Keyring).*
|
||||
|
||||
---
|
||||
|
||||
## Command Reference
|
||||
|
||||
### Komodo (`kmanage kmd`)
|
||||
|
||||
#### List Stacks
|
||||
```bash
|
||||
kmanage kmd list
|
||||
```
|
||||
Output:
|
||||
```text
|
||||
NAME SERVER STATUS PROVIDER REPO WEBHOOK
|
||||
---- ------ ------ -------- ---- -------
|
||||
authentik DS920 running git.hnrx.net Docker/authentik enabled
|
||||
beszel DS920 running git.hnrx.net Docker/beszel enabled
|
||||
manage-servers DS920 running github.com hnrx/manage-servers enabled
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Git Repositories (`kmanage git`)
|
||||
|
||||
#### List Repositories
|
||||
```bash
|
||||
# List repositories from default account:
|
||||
kmanage git list repos
|
||||
|
||||
# List repositories from a specific account (-A / --account):
|
||||
kmanage git list repos --account github
|
||||
|
||||
# List repositories across ALL configured accounts:
|
||||
kmanage git list repos --all-accounts
|
||||
```
|
||||
Output:
|
||||
```text
|
||||
ACCOUNT REPOSITORY STATUS BRANCH PR AUTO-DEL WEBHOOKS
|
||||
------- ---------- ------ ------ ----------- --------
|
||||
gitea Docker/authentik public main yes https://kmd.example.com/... (active)
|
||||
gitea Docker/beszel public main yes -
|
||||
github matthiashinrichs/docker-gitea public main no -
|
||||
...
|
||||
```
|
||||
|
||||
#### Initialize & Publish Current Directory as Git Repository (`kmanage git init-repo`)
|
||||
Creates a remote repository for the current project directory, initializes local git if needed, stages all files, creates an initial commit (`"Initial commit created by kmanage"`), and pushes to `main`.
|
||||
|
||||
```bash
|
||||
# Create repo with current directory name and push:
|
||||
kmanage git init-repo
|
||||
|
||||
# Create repo with custom name under an organization / owner:
|
||||
kmanage git init-repo my-service --org Docker
|
||||
|
||||
# Create a private repo on GitHub:
|
||||
kmanage git init-repo --account github --private
|
||||
|
||||
# Custom commit message and initial branch:
|
||||
kmanage git init-repo -b main -m "Initial commit created by kmanage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Git Webhooks (`kmanage git webhook`)
|
||||
|
||||
*Aliases: `hook`, `hooks`, `webhooks`*
|
||||
|
||||
#### 1. List Webhooks for a Repository
|
||||
```bash
|
||||
# In default account:
|
||||
kmanage git webhook list Docker/authentik
|
||||
|
||||
# In a specific account:
|
||||
kmanage git webhook list matthiashinrichs/docker-gitea --account github
|
||||
```
|
||||
|
||||
#### 2. Delete Webhooks from a Repository
|
||||
```bash
|
||||
# Delete single webhook by ID:
|
||||
kmanage git webhook delete Docker/authentik --id 12
|
||||
|
||||
# Delete all webhooks in repository:
|
||||
kmanage git webhook delete Docker/authentik --all
|
||||
|
||||
# Delete webhooks matching a target URL:
|
||||
kmanage git webhook delete Docker/authentik --url kmd.example.com
|
||||
|
||||
# Skip confirmation prompt (CI / Scripting):
|
||||
kmanage git webhook delete Docker/authentik --all -y
|
||||
```
|
||||
|
||||
#### 3. Webhook Prune (Search and batch delete across repositories)
|
||||
```bash
|
||||
# Dry-run preview:
|
||||
kmanage git webhook prune --url kmd.example.com --dry-run
|
||||
|
||||
# Prune in default account:
|
||||
kmanage git webhook prune --url kmd.example.com
|
||||
|
||||
# Prune across ALL configured Git accounts:
|
||||
kmanage git webhook prune --url kmd.example.com --all-accounts -y
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PR Branch Auto-Deletion (`kmanage git pr-autodel`)
|
||||
|
||||
*Aliases: `set-pr-autodel`, `autodel`*
|
||||
|
||||
Controls automatic deletion of head branches when pull requests are merged (compatible with Gitea `default_delete_branch_after_merge` and GitHub `delete_branch_on_merge`).
|
||||
|
||||
#### 1. Single Repository
|
||||
```bash
|
||||
# Enable:
|
||||
kmanage git pr-autodel enable Docker/authentik
|
||||
|
||||
# Disable on GitHub:
|
||||
kmanage git pr-autodel disable matthiashinrichs/docker-gitea --account github
|
||||
```
|
||||
|
||||
#### 2. All Repositories of an Owner / Organization
|
||||
```bash
|
||||
# Enable for all repositories in organization 'Docker':
|
||||
kmanage git pr-autodel enable --owner Docker
|
||||
|
||||
# Disable for all repositories in organization 'Docker':
|
||||
kmanage git pr-autodel disable --owner Docker -y
|
||||
```
|
||||
|
||||
#### 3. Globally for ALL Accessible Repositories
|
||||
```bash
|
||||
kmanage git pr-autodel enable --all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Global Options
|
||||
|
||||
| Flag | Description |
|
||||
| :--- | :--- |
|
||||
| `--no-header` | Omit table headers from output (ideal for scripting, `awk`, `grep`, or `fzf`). |
|
||||
| `-h`, `--help` | Display help for the respective command. |
|
||||
|
||||
### Example with `--no-header`:
|
||||
```bash
|
||||
kmanage kmd list --no-header | awk '{print $1, $3}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shell Auto-Completion
|
||||
|
||||
`kmanage` provides dynamic shell auto-completion for commands, flags, account names, owner names, and repository names.
|
||||
|
||||
### Zsh (macOS Default)
|
||||
|
||||
1. **Test in current session:**
|
||||
```zsh
|
||||
source <(kmanage completion zsh)
|
||||
```
|
||||
|
||||
2. **Persistent Setup:**
|
||||
```zsh
|
||||
mkdir -p ~/.zsh/completion
|
||||
kmanage completion zsh > ~/.zsh/completion/_kmanage
|
||||
```
|
||||
Add to your `~/.zshrc` (before `compinit`):
|
||||
```zsh
|
||||
fpath=(~/.zsh/completion $fpath)
|
||||
autoload -Uz compinit && compinit
|
||||
```
|
||||
|
||||
### Bash
|
||||
```bash
|
||||
source <(kmanage completion bash)
|
||||
```
|
||||
|
||||
### Fish
|
||||
```fish
|
||||
kmanage completion fish | source
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
kmanage/
|
||||
├── cmd/
|
||||
│ ├── root.go # Root Cobra command & global flags (--no-header)
|
||||
│ ├── kmd.go # Komodo parent command & kmd configure
|
||||
│ ├── kmd_list.go # kmanage kmd list
|
||||
│ ├── git.go # Git parent command, accounts, configure & list
|
||||
│ ├── git_webhook.go # kmanage git webhook list/delete/prune
|
||||
│ ├── git_pr_autodel.go # kmanage git pr-autodel enable/disable
|
||||
│ ├── helper_clients.go # Multi-account client resolution & input helpers
|
||||
│ ├── helper_config.go # Secure config storage (~/.kmanage.yaml)
|
||||
│ └── helper_completion.go # Dynamic auto-completion & local TTL cache
|
||||
├── pkg/
|
||||
│ ├── secret/
|
||||
│ │ └── secret.go # Cross-platform OS Keyring abstraction
|
||||
│ ├── komodo/
|
||||
│ │ └── client.go # Komodo Core API JSON-RPC client
|
||||
│ └── git/
|
||||
│ └── client.go # Multi-provider REST client (Gitea / GitHub)
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── main.go # Application entrypoint
|
||||
└── README.md # Documentation
|
||||
```
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"kmanage/pkg/git"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var gitCmd = &cobra.Command{
|
||||
Use: "git",
|
||||
Short: "Git repository management (Multi-Account: Gitea / Forgejo / GitHub)",
|
||||
Long: `Manage Git repositories, accounts, webhooks, and PR settings across multiple Git providers.`,
|
||||
}
|
||||
|
||||
var gitConfigureCmd = &cobra.Command{
|
||||
Use: "configure [flags]",
|
||||
Short: "Configure a Git provider account",
|
||||
Long: `Saves Git account metadata to ~/.kmanage.yaml and securely stores the Personal Access Token in the OS keyring.
|
||||
|
||||
Examples:
|
||||
# Configure a Gitea account:
|
||||
kmanage git configure --name gitea --url https://git.example.com --token my-access-token
|
||||
|
||||
# Configure a GitHub account:
|
||||
kmanage git configure --name github --type github --token ghp_myGitHubToken`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
accType, _ := cmd.Flags().GetString("type")
|
||||
url, _ := cmd.Flags().GetString("url")
|
||||
token, _ := cmd.Flags().GetString("token")
|
||||
username, _ := cmd.Flags().GetString("user")
|
||||
|
||||
if token == "" {
|
||||
_ = cmd.Help()
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return fmt.Errorf("❌ Error: The flag '--token' (or '-t') is required")
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
if strings.Contains(url, "github.com") || accType == "github" {
|
||||
name = "github"
|
||||
} else {
|
||||
name = "gitea"
|
||||
}
|
||||
}
|
||||
|
||||
if url == "" && (accType == "github" || name == "github") {
|
||||
url = "https://api.github.com"
|
||||
if accType == "" {
|
||||
accType = "github"
|
||||
}
|
||||
}
|
||||
|
||||
if url == "" {
|
||||
_ = cmd.Help()
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return fmt.Errorf("❌ Error: The flag '--url' (or '-u') is required")
|
||||
}
|
||||
|
||||
if err := SaveGitAccount(name, accType, url, token, username); err != nil {
|
||||
return fmt.Errorf("failed to save Git account: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Git account '%s' (%s) successfully saved (token stored in OS keyring).\n", name, url)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var gitAccountsCmd = &cobra.Command{
|
||||
Use: "accounts",
|
||||
Aliases: []string{"account-list", "list-accounts"},
|
||||
Short: "List all configured Git accounts",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := LoadAppConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(cfg.GitAccounts) == 0 {
|
||||
fmt.Println("No Git accounts configured. Run 'kmanage git configure --name <name> --url <url> --token <token>'")
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
noHeader, _ := cmd.Flags().GetBool("no-header")
|
||||
if !noHeader {
|
||||
fmt.Fprintln(w, "ACCOUNT\tTYPE\tURL\tDEFAULT")
|
||||
fmt.Fprintln(w, "-------\t----\t---\t-------")
|
||||
}
|
||||
|
||||
for name, acc := range cfg.GitAccounts {
|
||||
isDefault := "no"
|
||||
if name == cfg.DefaultGitAccount {
|
||||
isDefault = "yes (*)"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, acc.Type, acc.URL, isDefault)
|
||||
}
|
||||
|
||||
return w.Flush()
|
||||
},
|
||||
}
|
||||
|
||||
var gitSetDefaultCmd = &cobra.Command{
|
||||
Use: "set-default <account_name>",
|
||||
Short: "Set the default active Git account",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
accountName := args[0]
|
||||
if err := SetDefaultGitAccount(accountName); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✅ Default Git account set to '%s'.\n", accountName)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var gitDeleteAccountCmd = &cobra.Command{
|
||||
Use: "delete-account <account_name>",
|
||||
Aliases: []string{"rm-account", "del-account"},
|
||||
Short: "Delete a configured Git account",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
accountName := args[0]
|
||||
if err := DeleteGitAccount(accountName); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✅ Git account '%s' successfully deleted.\n", accountName)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var gitListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List Git resources (e.g. repos)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runListRepos(cmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
var gitListReposCmd = &cobra.Command{
|
||||
Use: "repos",
|
||||
Short: "List all repositories along with their webhooks",
|
||||
RunE: runListRepos,
|
||||
}
|
||||
|
||||
func runListRepos(cmd *cobra.Command, args []string) error {
|
||||
accountFlag, _ := cmd.Flags().GetString("account")
|
||||
allAccountsFlag, _ := cmd.Flags().GetBool("all-accounts")
|
||||
|
||||
clients, err := getSelectedGitClients(accountFlag, allAccountsFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var allReposWithHooks []git.RepositoryWithHooks
|
||||
for _, client := range clients {
|
||||
reposWithHooks, err := client.ListRepositoriesWithHooks()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "⚠️ Warning retrieving account '%s': %v\n", client.AccountName, err)
|
||||
continue
|
||||
}
|
||||
allReposWithHooks = append(allReposWithHooks, reposWithHooks...)
|
||||
}
|
||||
|
||||
if len(allReposWithHooks) == 0 {
|
||||
fmt.Println("No repositories found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
|
||||
noHeader, _ := cmd.Flags().GetBool("no-header")
|
||||
if !noHeader {
|
||||
if allAccountsFlag || len(clients) > 1 {
|
||||
fmt.Fprintln(w, "ACCOUNT\tREPOSITORY\tSTATUS\tBRANCH\tPR AUTO-DEL\tWEBHOOKS")
|
||||
fmt.Fprintln(w, "-------\t----------\t------\t------\t-----------\t--------")
|
||||
} else {
|
||||
fmt.Fprintln(w, "REPOSITORY\tSTATUS\tBRANCH\tPR AUTO-DEL\tWEBHOOKS")
|
||||
fmt.Fprintln(w, "----------\t------\t------\t-----------\t--------")
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range allReposWithHooks {
|
||||
r := item.Repository
|
||||
visibility := "public"
|
||||
if r.Private {
|
||||
visibility = "private"
|
||||
}
|
||||
if r.Fork {
|
||||
visibility += " (fork)"
|
||||
}
|
||||
|
||||
branch := r.DefaultBranch
|
||||
if branch == "" {
|
||||
branch = "-"
|
||||
}
|
||||
|
||||
prAutoDel := "no"
|
||||
if r.IsAutoDeleteBranch() {
|
||||
prAutoDel = "yes"
|
||||
}
|
||||
|
||||
var hookStrings []string
|
||||
for _, h := range item.Hooks {
|
||||
target := h.TargetURL()
|
||||
statusStr := "active"
|
||||
if !h.Active {
|
||||
statusStr = "inactive"
|
||||
}
|
||||
hookStrings = append(hookStrings, fmt.Sprintf("%s (%s)", target, statusStr))
|
||||
}
|
||||
|
||||
webhooksDisplay := "-"
|
||||
if len(hookStrings) > 0 {
|
||||
webhooksDisplay = strings.Join(hookStrings, ", ")
|
||||
}
|
||||
|
||||
if allAccountsFlag || len(clients) > 1 {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", r.AccountName, r.FullName, visibility, branch, prAutoDel, webhooksDisplay)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.FullName, visibility, branch, prAutoDel, webhooksDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(gitCmd)
|
||||
gitCmd.AddCommand(gitConfigureCmd)
|
||||
gitCmd.AddCommand(gitAccountsCmd)
|
||||
gitCmd.AddCommand(gitSetDefaultCmd)
|
||||
gitCmd.AddCommand(gitDeleteAccountCmd)
|
||||
gitCmd.AddCommand(gitListCmd)
|
||||
gitListCmd.AddCommand(gitListReposCmd)
|
||||
|
||||
gitConfigureCmd.Flags().StringP("name", "n", "", "Unique name for the Git account (e.g. 'gitea', 'github', 'work')")
|
||||
gitConfigureCmd.Flags().StringP("type", "", "", "Git provider type: 'gitea', 'forgejo', or 'github' (optional, auto-detected)")
|
||||
gitConfigureCmd.Flags().StringP("url", "u", "", "Git API Base URL (e.g. https://git.example.com or https://api.github.com)")
|
||||
gitConfigureCmd.Flags().StringP("token", "t", "", "Git Personal Access Token (required)")
|
||||
gitConfigureCmd.Flags().StringP("user", "", "", "Optional username")
|
||||
|
||||
for _, c := range []*cobra.Command{gitListCmd, gitListReposCmd} {
|
||||
c.Flags().StringP("account", "A", "", "Select a specific Git account (default: configured default account)")
|
||||
c.Flags().BoolP("all-accounts", "", false, "List repositories across ALL configured Git accounts")
|
||||
_ = c.RegisterFlagCompletionFunc("account", completeGitAccountNames)
|
||||
}
|
||||
|
||||
_ = gitSetDefaultCmd.RegisterFlagCompletionFunc("default", completeGitAccountNames)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kmanage/pkg/git"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var gitInitRepoCmd = &cobra.Command{
|
||||
Use: "init-repo [name]",
|
||||
Aliases: []string{"init", "create", "new"},
|
||||
Short: "Create a remote Git repository and push the current project directory",
|
||||
Long: `Creates a new remote repository on your configured Git provider (Gitea / Forgejo / GitHub)
|
||||
for the current working directory, initializes Git locally if needed, creates an initial commit,
|
||||
and pushes the branch to remote origin.
|
||||
|
||||
Examples:
|
||||
# Create repo with current directory name and push:
|
||||
kmanage git init-repo
|
||||
|
||||
# Create repo with custom name under an organization:
|
||||
kmanage git init-repo my-service --org Docker
|
||||
|
||||
# Create private repo on GitHub:
|
||||
kmanage git init-repo --account github --private
|
||||
|
||||
# Custom branch and commit message:
|
||||
kmanage git init-repo -b main -m "Initial commit created by kmanage"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current working directory: %w", err)
|
||||
}
|
||||
|
||||
repoName := filepath.Base(cwd)
|
||||
if len(args) > 0 && strings.TrimSpace(args[0]) != "" {
|
||||
repoName = strings.TrimSpace(args[0])
|
||||
}
|
||||
|
||||
accountFlag, _ := cmd.Flags().GetString("account")
|
||||
orgFlag, _ := cmd.Flags().GetString("org")
|
||||
ownerFlag, _ := cmd.Flags().GetString("owner")
|
||||
if orgFlag == "" && ownerFlag != "" {
|
||||
orgFlag = ownerFlag
|
||||
}
|
||||
|
||||
privateFlag, _ := cmd.Flags().GetBool("private")
|
||||
descFlag, _ := cmd.Flags().GetString("description")
|
||||
branchFlag, _ := cmd.Flags().GetString("branch")
|
||||
if branchFlag == "" {
|
||||
branchFlag = "main"
|
||||
}
|
||||
commitMsg, _ := cmd.Flags().GetString("message")
|
||||
if commitMsg == "" {
|
||||
commitMsg = "Initial commit created by kmanage"
|
||||
}
|
||||
useHTTPS, _ := cmd.Flags().GetBool("https")
|
||||
|
||||
// 1. Verify Git CLI is available
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
return fmt.Errorf("git binary not found in PATH. Please install git")
|
||||
}
|
||||
|
||||
// 2. Resolve Git client
|
||||
client, err := getGitClientForAccount(accountFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetOwner := orgFlag
|
||||
if targetOwner == "" {
|
||||
targetOwner = client.AccountName
|
||||
}
|
||||
|
||||
fmt.Printf("🔨 Creating remote repository '%s' on %s (%s)...\n", repoName, client.AccountName, client.BaseURL)
|
||||
|
||||
createdRepo, err := client.CreateRepository(git.CreateRepoOptions{
|
||||
Name: repoName,
|
||||
Description: descFlag,
|
||||
Private: privateFlag,
|
||||
AutoInit: false,
|
||||
DefaultBranch: branchFlag,
|
||||
Org: orgFlag,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create remote repository: %w", err)
|
||||
}
|
||||
|
||||
// 3. Determine remote URL (prefer SSH unless HTTPS flag is specified)
|
||||
remoteURL := createdRepo.SSHURL
|
||||
if useHTTPS || remoteURL == "" {
|
||||
remoteURL = createdRepo.CloneURL
|
||||
}
|
||||
if remoteURL == "" {
|
||||
remoteURL = createdRepo.HTMLURL
|
||||
}
|
||||
|
||||
// 4. Initialize local git repository if not already initialized
|
||||
fmt.Printf("📦 Setting up local git repository in %s...\n", cwd)
|
||||
if !isGitRepo(cwd) {
|
||||
if err := runGitCmd(cwd, "init"); err != nil {
|
||||
return fmt.Errorf("failed to initialize git repository: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Ensure desired branch name (e.g. main)
|
||||
_ = runGitCmd(cwd, "branch", "-M", branchFlag)
|
||||
|
||||
// 6. Configure remote origin
|
||||
if hasRemote(cwd, "origin") {
|
||||
if err := runGitCmd(cwd, "remote", "set-url", "origin", remoteURL); err != nil {
|
||||
return fmt.Errorf("failed to update remote origin: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := runGitCmd(cwd, "remote", "add", "origin", remoteURL); err != nil {
|
||||
return fmt.Errorf("failed to add remote origin: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Stage all files
|
||||
fmt.Println("📝 Staging files...")
|
||||
if err := runGitCmd(cwd, "add", "-A"); err != nil {
|
||||
return fmt.Errorf("failed to stage files: %w", err)
|
||||
}
|
||||
|
||||
// 8. Create commit if needed
|
||||
if hasUncommittedChanges(cwd) {
|
||||
fmt.Printf("📝 Creating commit (\"%s\")...\n", commitMsg)
|
||||
if err := runGitCmd(cwd, "commit", "-m", commitMsg); err != nil {
|
||||
return fmt.Errorf("failed to commit: %w", err)
|
||||
}
|
||||
} else if !hasAnyCommits(cwd) {
|
||||
fmt.Printf("📝 Creating initial commit (\"%s\")...\n", commitMsg)
|
||||
if err := runGitCmd(cwd, "commit", "--allow-empty", "-m", commitMsg); err != nil {
|
||||
return fmt.Errorf("failed to commit: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Push to remote origin
|
||||
fmt.Printf("🚀 Pushing branch '%s' to remote origin...\n", branchFlag)
|
||||
if err := runGitCmd(cwd, "push", "-u", "origin", branchFlag); err != nil {
|
||||
return fmt.Errorf("failed to push to remote repository (%s): %w\nMake sure your SSH key or Git credentials are configured for remote access", remoteURL, err)
|
||||
}
|
||||
|
||||
fmt.Println("\n✅ Repository successfully created and published!")
|
||||
if createdRepo.HTMLURL != "" {
|
||||
fmt.Printf(" 🔗 Web URL: %s\n", createdRepo.HTMLURL)
|
||||
}
|
||||
fmt.Printf(" 📡 Remote: %s\n", remoteURL)
|
||||
fmt.Printf(" 🌿 Branch: %s\n", branchFlag)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func isGitRepo(dir string) bool {
|
||||
cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree")
|
||||
cmd.Dir = dir
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func hasRemote(dir, remoteName string) bool {
|
||||
cmd := exec.Command("git", "remote", "get-url", remoteName)
|
||||
cmd.Dir = dir
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func hasUncommittedChanges(dir string) bool {
|
||||
cmd := exec.Command("git", "status", "--porcelain")
|
||||
cmd.Dir = dir
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(out.String()) != ""
|
||||
}
|
||||
|
||||
func hasAnyCommits(dir string) bool {
|
||||
cmd := exec.Command("git", "rev-parse", "--verify", "HEAD")
|
||||
cmd.Dir = dir
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func runGitCmd(dir string, args ...string) error {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
errStr := strings.TrimSpace(stderr.String())
|
||||
if errStr != "" {
|
||||
return fmt.Errorf("%s", errStr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
gitCmd.AddCommand(gitInitRepoCmd)
|
||||
|
||||
gitInitRepoCmd.Flags().StringP("account", "A", "", "Git account to create the repository on (default: active default account)")
|
||||
gitInitRepoCmd.Flags().StringP("org", "o", "", "Organization or owner under which to create the repository")
|
||||
gitInitRepoCmd.Flags().String("owner", "", "Alias for --org")
|
||||
gitInitRepoCmd.Flags().BoolP("private", "p", false, "Make the repository private")
|
||||
gitInitRepoCmd.Flags().StringP("description", "d", "", "Repository description")
|
||||
gitInitRepoCmd.Flags().StringP("branch", "b", "main", "Initial/default branch name")
|
||||
gitInitRepoCmd.Flags().StringP("message", "m", "Initial commit created by kmanage", "Commit message for the initial commit")
|
||||
gitInitRepoCmd.Flags().Bool("https", false, "Use HTTPS clone URL instead of SSH for git remote")
|
||||
|
||||
_ = gitInitRepoCmd.RegisterFlagCompletionFunc("account", completeGitAccountNames)
|
||||
_ = gitInitRepoCmd.RegisterFlagCompletionFunc("org", completeOwnerNames)
|
||||
_ = gitInitRepoCmd.RegisterFlagCompletionFunc("owner", completeOwnerNames)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"kmanage/pkg/git"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var gitPRAutoDelCmd = &cobra.Command{
|
||||
Use: "pr-autodel",
|
||||
Aliases: []string{"set-pr-autodel", "pr-branch-del", "autodel"},
|
||||
Short: "Enable or disable automatic PR branch deletion after merge",
|
||||
Long: `Controls the repository setting to automatically delete head branches upon pull request merge.
|
||||
Compatible with Gitea, Forgejo, and GitHub (delete_branch_on_merge).
|
||||
Can be applied to a single repository, all repositories of an owner/organization, or all accessible repositories.
|
||||
|
||||
Examples:
|
||||
# Enable or disable for a single repository:
|
||||
kmanage git pr-autodel enable Docker/authentik
|
||||
kmanage git pr-autodel disable Docker/authentik
|
||||
|
||||
# For all repositories of an owner/organization (e.g. 'Docker'):
|
||||
kmanage git pr-autodel enable --owner Docker
|
||||
kmanage git pr-autodel disable --owner Docker -y
|
||||
|
||||
# On a specific Git account (e.g. GitHub):
|
||||
kmanage git pr-autodel enable --account github --owner my-org
|
||||
|
||||
# Globally for ALL accessible repositories:
|
||||
kmanage git pr-autodel enable --all`,
|
||||
}
|
||||
|
||||
var gitPREnableCmd = &cobra.Command{
|
||||
Use: "enable [owner/repo]",
|
||||
Aliases: []string{"on", "1", "true"},
|
||||
Short: "Enable automatic deletion of PR branches after merge",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return handleSetPRAutoDel(cmd, args, true)
|
||||
},
|
||||
}
|
||||
|
||||
var gitPRDisableCmd = &cobra.Command{
|
||||
Use: "disable [owner/repo]",
|
||||
Aliases: []string{"off", "0", "false"},
|
||||
Short: "Disable automatic deletion of PR branches after merge",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return handleSetPRAutoDel(cmd, args, false)
|
||||
},
|
||||
}
|
||||
|
||||
func handleSetPRAutoDel(cmd *cobra.Command, args []string, enable bool) error {
|
||||
ownerFlag, _ := cmd.Flags().GetString("owner")
|
||||
allFlag, _ := cmd.Flags().GetBool("all")
|
||||
autoYes, _ := cmd.Flags().GetBool("yes")
|
||||
accountFlag, _ := cmd.Flags().GetString("account")
|
||||
allAccountsFlag, _ := cmd.Flags().GetBool("all-accounts")
|
||||
|
||||
actionName := "enable"
|
||||
stateName := "enabled"
|
||||
if !enable {
|
||||
actionName = "disable"
|
||||
stateName = "disabled"
|
||||
}
|
||||
|
||||
// Case 1: Single repository specified as argument
|
||||
if len(args) > 0 {
|
||||
owner, repo, err := parseRepoArg(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := getGitClientForAccount(accountFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updatedRepo, err := client.SetAutoDeleteBranch(owner, repo, enable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update %s/%s (%s): %w", owner, repo, client.AccountName, err)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ PR Auto-Delete for '%s' (%s) successfully %s (Current value: %v).\n",
|
||||
updatedRepo.FullName, client.AccountName, stateName, updatedRepo.IsAutoDeleteBranch())
|
||||
return nil
|
||||
}
|
||||
|
||||
// Case 2: Multiple repositories via --owner or --all
|
||||
if ownerFlag == "" && !allFlag {
|
||||
_ = cmd.Help()
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return fmt.Errorf("❌ Error: Specify a repository (e.g. 'Docker/authentik') or use '--owner <name>' / '--all'")
|
||||
}
|
||||
|
||||
clients, err := getSelectedGitClients(accountFlag, allAccountsFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type targetRepo struct {
|
||||
Repo git.Repository
|
||||
Client *git.Client
|
||||
}
|
||||
|
||||
var targets []targetRepo
|
||||
for _, client := range clients {
|
||||
repos, err := client.ListRepositories()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "⚠️ Warning retrieving account '%s': %v\n", client.AccountName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, r := range repos {
|
||||
if allFlag {
|
||||
targets = append(targets, targetRepo{Repo: r, Client: client})
|
||||
} else if ownerFlag != "" {
|
||||
ownerName := r.Owner.UserName
|
||||
if ownerName == "" && strings.Contains(r.FullName, "/") {
|
||||
ownerName = strings.SplitN(r.FullName, "/", 2)[0]
|
||||
}
|
||||
if strings.EqualFold(ownerName, ownerFlag) {
|
||||
targets = append(targets, targetRepo{Repo: r, Client: client})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
if ownerFlag != "" {
|
||||
fmt.Printf("No repositories found for owner '%s'.\n", ownerFlag)
|
||||
} else {
|
||||
fmt.Println("No repositories found.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
scopeDescription := fmt.Sprintf("all %d repositories", len(targets))
|
||||
if ownerFlag != "" {
|
||||
scopeDescription = fmt.Sprintf("all %d repositories of owner '%s'", len(targets), ownerFlag)
|
||||
}
|
||||
|
||||
if !autoYes {
|
||||
prompt := fmt.Sprintf("Do you really want to %s PR Auto-Delete for %s?", actionName, scopeDescription)
|
||||
if !confirmAction(prompt) {
|
||||
fmt.Println("Cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nUpdating %d repositories...\n", len(targets))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, 8)
|
||||
var successCount int
|
||||
var countMu sync.Mutex
|
||||
|
||||
for _, t := range targets {
|
||||
wg.Add(1)
|
||||
go func(item targetRepo) {
|
||||
defer wg.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
ownerName := item.Repo.Owner.UserName
|
||||
if ownerName == "" && strings.Contains(item.Repo.FullName, "/") {
|
||||
ownerName = strings.SplitN(item.Repo.FullName, "/", 2)[0]
|
||||
}
|
||||
repoName := item.Repo.Name
|
||||
if repoName == "" && strings.Contains(item.Repo.FullName, "/") {
|
||||
repoName = strings.SplitN(item.Repo.FullName, "/", 2)[1]
|
||||
}
|
||||
|
||||
_, err := item.Client.SetAutoDeleteBranch(ownerName, repoName, enable)
|
||||
|
||||
countMu.Lock()
|
||||
defer countMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Error updating [%s] %s: %v\n", item.Client.AccountName, item.Repo.FullName, err)
|
||||
} else {
|
||||
fmt.Printf("✅ [%s] %-30s -> PR Auto-Delete %s\n", item.Client.AccountName, item.Repo.FullName, stateName)
|
||||
successCount++
|
||||
}
|
||||
}(t)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
fmt.Printf("\nDone: %d of %d repositories successfully updated.\n", successCount, len(targets))
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
gitCmd.AddCommand(gitPRAutoDelCmd)
|
||||
gitPRAutoDelCmd.AddCommand(gitPREnableCmd)
|
||||
gitPRAutoDelCmd.AddCommand(gitPRDisableCmd)
|
||||
|
||||
gitPREnableCmd.ValidArgsFunction = completeRepoNames
|
||||
gitPRDisableCmd.ValidArgsFunction = completeRepoNames
|
||||
|
||||
for _, c := range []*cobra.Command{gitPREnableCmd, gitPRDisableCmd} {
|
||||
c.Flags().StringP("owner", "o", "", "Apply to all repositories belonging to this owner/organization")
|
||||
c.Flags().BoolP("all", "a", false, "Apply to all accessible repositories")
|
||||
c.Flags().BoolP("yes", "y", false, "Skip interactive confirmation prompt")
|
||||
c.Flags().StringP("account", "A", "", "Select specific Git account (default: configured default account)")
|
||||
c.Flags().Bool("all-accounts", false, "Apply across all configured Git accounts")
|
||||
|
||||
_ = c.RegisterFlagCompletionFunc("owner", completeOwnerNames)
|
||||
_ = c.RegisterFlagCompletionFunc("account", completeGitAccountNames)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/tabwriter"
|
||||
|
||||
"kmanage/pkg/git"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var gitWebhookCmd = &cobra.Command{
|
||||
Use: "webhook",
|
||||
Aliases: []string{"webhooks", "hook", "hooks"},
|
||||
Short: "Manage Git webhooks",
|
||||
Long: `Commands to list, delete, and prune webhooks in Git repositories.`,
|
||||
}
|
||||
|
||||
var gitWebhookListCmd = &cobra.Command{
|
||||
Use: "list <owner/repo>",
|
||||
Short: "List all webhooks configured for a specific repository",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
owner, repo, err := parseRepoArg(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accountFlag, _ := cmd.Flags().GetString("account")
|
||||
client, err := getGitClientForAccount(accountFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hooks, err := client.ListHooks(owner, repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve webhooks for %s/%s (%s): %w", owner, repo, client.AccountName, err)
|
||||
}
|
||||
|
||||
if len(hooks) == 0 {
|
||||
fmt.Printf("No webhooks found in repository %s/%s (%s).\n", owner, repo, client.AccountName)
|
||||
return nil
|
||||
}
|
||||
|
||||
noHeader, _ := cmd.Flags().GetBool("no-header")
|
||||
if !noHeader {
|
||||
fmt.Printf("Webhooks for %s/%s (Account: %s):\n", owner, repo, client.AccountName)
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
if !noHeader {
|
||||
fmt.Fprintln(w, "ID\tTYPE\tSTATUS\tEVENTS\tTARGET URL")
|
||||
fmt.Fprintln(w, "--\t----\t------\t------\t----------")
|
||||
}
|
||||
|
||||
for _, h := range hooks {
|
||||
status := "active"
|
||||
if !h.Active {
|
||||
status = "inactive"
|
||||
}
|
||||
events := strings.Join(h.Events, ",")
|
||||
if events == "" {
|
||||
events = "-"
|
||||
}
|
||||
hookType := h.Type
|
||||
if hookType == "" {
|
||||
hookType = h.Name
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", h.ID, hookType, status, events, h.TargetURL())
|
||||
}
|
||||
|
||||
return w.Flush()
|
||||
},
|
||||
}
|
||||
|
||||
var gitWebhookDeleteCmd = &cobra.Command{
|
||||
Use: "delete <owner/repo>",
|
||||
Aliases: []string{"del", "remove", "rm"},
|
||||
Short: "Delete webhooks from a repository",
|
||||
Long: `Deletes webhooks from a repository by ID, URL filter, or all at once.
|
||||
|
||||
Examples:
|
||||
kmanage git webhook delete Docker/authentik --id 12
|
||||
kmanage git webhook delete Docker/authentik --all
|
||||
kmanage git webhook delete Docker/authentik --url kmd.example.com`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
owner, repo, err := parseRepoArg(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hookID, _ := cmd.Flags().GetInt64("id")
|
||||
deleteAll, _ := cmd.Flags().GetBool("all")
|
||||
urlFilter, _ := cmd.Flags().GetString("url")
|
||||
autoYes, _ := cmd.Flags().GetBool("yes")
|
||||
accountFlag, _ := cmd.Flags().GetString("account")
|
||||
|
||||
if hookID == 0 && !deleteAll && urlFilter == "" {
|
||||
_ = cmd.Help()
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return fmt.Errorf("❌ Error: Specify what to delete:\n --id <id> (single webhook)\n --all (all webhooks in repo)\n --url <url> (webhooks matching target URL)")
|
||||
}
|
||||
|
||||
client, err := getGitClientForAccount(accountFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hookID != 0 {
|
||||
if !autoYes {
|
||||
if !confirmAction(fmt.Sprintf("Do you really want to delete webhook #%d in %s/%s?", hookID, owner, repo)) {
|
||||
fmt.Println("Cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
err := client.DeleteHook(owner, repo, hookID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete webhook #%d: %w", hookID, err)
|
||||
}
|
||||
fmt.Printf("✅ Webhook #%d in %s/%s successfully deleted.\n", hookID, owner, repo)
|
||||
return nil
|
||||
}
|
||||
|
||||
hooks, err := client.ListHooks(owner, repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve webhooks for %s/%s: %w", owner, repo, err)
|
||||
}
|
||||
|
||||
var targets []git.Hook
|
||||
for _, h := range hooks {
|
||||
if deleteAll {
|
||||
targets = append(targets, h)
|
||||
} else if urlFilter != "" && strings.Contains(h.TargetURL(), urlFilter) {
|
||||
targets = append(targets, h)
|
||||
}
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
fmt.Println("No matching webhooks found to delete.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("The following webhooks in %s/%s (%s) will be deleted:\n", owner, repo, client.AccountName)
|
||||
for _, t := range targets {
|
||||
fmt.Printf(" - ID %d: %s\n", t.ID, t.TargetURL())
|
||||
}
|
||||
|
||||
if !autoYes {
|
||||
if !confirmAction(fmt.Sprintf("Confirm deletion of %d webhook(s)?", len(targets))) {
|
||||
fmt.Println("Cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for _, t := range targets {
|
||||
if err := client.DeleteHook(owner, repo, t.ID); err != nil {
|
||||
fmt.Printf("❌ Failed to delete webhook #%d (%s): %v\n", t.ID, t.TargetURL(), err)
|
||||
} else {
|
||||
fmt.Printf("✅ Deleted webhook #%d (%s)\n", t.ID, t.TargetURL())
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nSuccessfully deleted %d of %d webhook(s).\n", successCount, len(targets))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type matchedHook struct {
|
||||
Account string
|
||||
Owner string
|
||||
Repo string
|
||||
HookID int64
|
||||
URL string
|
||||
Type string
|
||||
Active bool
|
||||
RepoRef string
|
||||
Client *git.Client
|
||||
}
|
||||
|
||||
var gitWebhookPruneCmd = &cobra.Command{
|
||||
Use: "prune [flags]",
|
||||
Aliases: []string{"clean", "cleanup", "delete-all", "remove-all"},
|
||||
Short: "Delete webhooks matching a specific target URL across repositories",
|
||||
Long: `Searches repositories for webhooks with the given target URL and batch deletes them.
|
||||
Supports pruning within the default account, a specific account, or across all configured accounts using '--all-accounts'.
|
||||
|
||||
Examples:
|
||||
# Preview matching webhooks without deleting (Dry-Run):
|
||||
kmanage git webhook prune --url kmd.example.com --dry-run
|
||||
|
||||
# Prune matching webhooks in default account:
|
||||
kmanage git webhook prune --url kmd.example.com
|
||||
|
||||
# Prune matching webhooks across ALL configured accounts:
|
||||
kmanage git webhook prune --url kmd.example.com --all-accounts -y`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
urlFilter, _ := cmd.Flags().GetString("url")
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
autoYes, _ := cmd.Flags().GetBool("yes")
|
||||
accountFlag, _ := cmd.Flags().GetString("account")
|
||||
allAccountsFlag, _ := cmd.Flags().GetBool("all-accounts")
|
||||
|
||||
if urlFilter == "" {
|
||||
_ = cmd.Help()
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return fmt.Errorf("❌ Error: The flag '--url' (or '-u') is required")
|
||||
}
|
||||
|
||||
clients, err := getSelectedGitClients(accountFlag, allAccountsFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("🔍 Searching repositories for webhooks matching '%s'...\n", urlFilter)
|
||||
|
||||
var matches []matchedHook
|
||||
for _, client := range clients {
|
||||
reposWithHooks, err := client.ListRepositoriesWithHooks()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "⚠️ Warning searching account '%s': %v\n", client.AccountName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, r := range reposWithHooks {
|
||||
owner, repo, err := parseRepoArg(r.FullName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, h := range r.Hooks {
|
||||
if strings.Contains(h.TargetURL(), urlFilter) {
|
||||
matches = append(matches, matchedHook{
|
||||
Account: client.AccountName,
|
||||
Owner: owner,
|
||||
Repo: repo,
|
||||
HookID: h.ID,
|
||||
URL: h.TargetURL(),
|
||||
Type: h.Type,
|
||||
Active: h.Active,
|
||||
RepoRef: r.FullName,
|
||||
Client: client,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
fmt.Printf("No webhooks found matching URL filter '%s'.\n", urlFilter)
|
||||
return nil
|
||||
}
|
||||
|
||||
noHeader, _ := cmd.Flags().GetBool("no-header")
|
||||
if !noHeader {
|
||||
fmt.Printf("\nMatching Webhooks (%d):\n", len(matches))
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
if !noHeader {
|
||||
if len(clients) > 1 || allAccountsFlag {
|
||||
fmt.Fprintln(w, "ACCOUNT\tREPOSITORY\tID\tTYPE\tSTATUS\tTARGET URL")
|
||||
fmt.Fprintln(w, "-------\t----------\t--\t----\t------\t----------")
|
||||
} else {
|
||||
fmt.Fprintln(w, "REPOSITORY\tID\tTYPE\tSTATUS\tTARGET URL")
|
||||
fmt.Fprintln(w, "----------\t--\t----\t------\t----------")
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range matches {
|
||||
status := "active"
|
||||
if !m.Active {
|
||||
status = "inactive"
|
||||
}
|
||||
hookType := m.Type
|
||||
if hookType == "" {
|
||||
hookType = "-"
|
||||
}
|
||||
if len(clients) > 1 || allAccountsFlag {
|
||||
fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\t%s\n", m.Account, m.RepoRef, m.HookID, hookType, status, m.URL)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\n", m.RepoRef, m.HookID, hookType, status, m.URL)
|
||||
}
|
||||
}
|
||||
_ = w.Flush()
|
||||
fmt.Println()
|
||||
|
||||
if dryRun {
|
||||
fmt.Println("ℹ️ Dry-run mode active. No webhooks were deleted.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !autoYes {
|
||||
prompt := fmt.Sprintf("Do you really want to delete all %d matching webhook(s)?", len(matches))
|
||||
if !confirmAction(prompt) {
|
||||
fmt.Println("Cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\nDeleting webhooks...")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, 8)
|
||||
var successCount int
|
||||
var countMu sync.Mutex
|
||||
|
||||
for _, m := range matches {
|
||||
wg.Add(1)
|
||||
go func(item matchedHook) {
|
||||
defer wg.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
err := item.Client.DeleteHook(item.Owner, item.Repo, item.HookID)
|
||||
countMu.Lock()
|
||||
defer countMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Error deleting [%s] %s (Webhook #%d): %v\n", item.Account, item.RepoRef, item.HookID, err)
|
||||
} else {
|
||||
fmt.Printf("✅ Deleted: [%s] %s -> Webhook #%d (%s)\n", item.Account, item.RepoRef, item.HookID, item.URL)
|
||||
successCount++
|
||||
}
|
||||
}(m)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
fmt.Printf("\nDone: %d of %d webhook(s) successfully deleted.\n", successCount, len(matches))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
gitCmd.AddCommand(gitWebhookCmd)
|
||||
gitWebhookCmd.AddCommand(gitWebhookListCmd)
|
||||
gitWebhookCmd.AddCommand(gitWebhookDeleteCmd)
|
||||
gitWebhookCmd.AddCommand(gitWebhookPruneCmd)
|
||||
|
||||
gitWebhookListCmd.ValidArgsFunction = completeRepoNames
|
||||
gitWebhookDeleteCmd.ValidArgsFunction = completeRepoNames
|
||||
|
||||
for _, c := range []*cobra.Command{gitWebhookListCmd, gitWebhookDeleteCmd} {
|
||||
c.Flags().StringP("account", "A", "", "Select specific Git account (default: configured default account)")
|
||||
_ = c.RegisterFlagCompletionFunc("account", completeGitAccountNames)
|
||||
}
|
||||
|
||||
gitWebhookDeleteCmd.Flags().Int64P("id", "i", 0, "ID of the webhook to delete")
|
||||
gitWebhookDeleteCmd.Flags().BoolP("all", "a", false, "Delete all webhooks in the repository")
|
||||
gitWebhookDeleteCmd.Flags().StringP("url", "u", "", "Delete webhooks whose target URL contains this string")
|
||||
gitWebhookDeleteCmd.Flags().BoolP("yes", "y", false, "Skip interactive confirmation prompt")
|
||||
|
||||
gitWebhookPruneCmd.Flags().StringP("url", "u", "", "Target URL filter of webhooks to delete (required)")
|
||||
gitWebhookPruneCmd.Flags().Bool("dry-run", false, "Preview webhooks that would be deleted without modifying anything")
|
||||
gitWebhookPruneCmd.Flags().BoolP("yes", "y", false, "Skip interactive confirmation prompt")
|
||||
gitWebhookPruneCmd.Flags().StringP("account", "A", "", "Select specific Git account")
|
||||
gitWebhookPruneCmd.Flags().Bool("all-accounts", false, "Search and delete across all configured Git accounts")
|
||||
_ = gitWebhookPruneCmd.RegisterFlagCompletionFunc("account", completeGitAccountNames)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"kmanage/pkg/git"
|
||||
"kmanage/pkg/komodo"
|
||||
"kmanage/pkg/secret"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// getKomodoClient initializes the Komodo client, resolving credentials from the keyring, environment, or config file.
|
||||
func getKomodoClient() (*komodo.Client, error) {
|
||||
cfg, err := LoadAppConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := cfg.KomodoURL
|
||||
if url == "" {
|
||||
url = viper.GetString("komodo_url")
|
||||
}
|
||||
if url == "" {
|
||||
return nil, fmt.Errorf("Komodo URL not configured. Run 'kmanage kmd configure'")
|
||||
}
|
||||
|
||||
key, _ := secret.GetSecret("komodo:key")
|
||||
if key == "" {
|
||||
key = cfg.KomodoKey
|
||||
}
|
||||
if key == "" {
|
||||
key = viper.GetString("komodo_key")
|
||||
}
|
||||
|
||||
sec, _ := secret.GetSecret("komodo:secret")
|
||||
if sec == "" {
|
||||
sec = cfg.KomodoSecret
|
||||
}
|
||||
if sec == "" {
|
||||
sec = viper.GetString("komodo_secret")
|
||||
}
|
||||
|
||||
if key == "" || sec == "" {
|
||||
return nil, fmt.Errorf("incomplete Komodo credentials. Run 'kmanage kmd configure'")
|
||||
}
|
||||
|
||||
return komodo.NewClient(url, key, sec), nil
|
||||
}
|
||||
|
||||
// getGitClientForAccount initializes a Git client for a specific account name or the configured default account.
|
||||
func getGitClientForAccount(accountName string) (*git.Client, error) {
|
||||
cfg, err := LoadAppConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(cfg.GitAccounts) == 0 {
|
||||
return nil, fmt.Errorf("no Git account configured. Run 'kmanage git configure --url <url> --token <token>'")
|
||||
}
|
||||
|
||||
target := accountName
|
||||
if target == "" {
|
||||
target = cfg.DefaultGitAccount
|
||||
}
|
||||
if target == "" {
|
||||
for k := range cfg.GitAccounts {
|
||||
target = k
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
acc, exists := cfg.GitAccounts[target]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("Git account '%s' not found. View configured accounts with 'kmanage git accounts'", target)
|
||||
}
|
||||
|
||||
token, _ := secret.GetSecret("git:" + target + ":token")
|
||||
if token == "" {
|
||||
token = acc.Token
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("token for Git account '%s' not found. Reconfigure using 'kmanage git configure --name %s --token <token>'", target, target)
|
||||
}
|
||||
|
||||
return git.NewClient(target, acc.URL, token, git.ProviderType(acc.Type)), nil
|
||||
}
|
||||
|
||||
// getSelectedGitClients returns relevant clients based on --account or --all-accounts flags.
|
||||
func getSelectedGitClients(accountFlag string, allAccountsFlag bool) ([]*git.Client, error) {
|
||||
cfg, err := LoadAppConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(cfg.GitAccounts) == 0 {
|
||||
return nil, fmt.Errorf("no Git account configured. Run 'kmanage git configure --url <url> --token <token>'")
|
||||
}
|
||||
|
||||
if allAccountsFlag {
|
||||
var clients []*git.Client
|
||||
for name := range cfg.GitAccounts {
|
||||
c, err := getGitClientForAccount(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clients = append(clients, c)
|
||||
}
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
client, err := getGitClientForAccount(accountFlag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*git.Client{client}, nil
|
||||
}
|
||||
|
||||
// parseRepoArg splits repository arguments (e.g. 'Docker/authentik' or 'https://git.hnrx.net/Docker/authentik.git') into owner and repo name.
|
||||
func parseRepoArg(repoArg string) (string, string, error) {
|
||||
repoArg = strings.TrimSpace(repoArg)
|
||||
repoArg = strings.TrimPrefix(repoArg, "https://")
|
||||
repoArg = strings.TrimPrefix(repoArg, "http://")
|
||||
if strings.Contains(repoArg, "/") {
|
||||
parts := strings.Split(repoArg, "/")
|
||||
if len(parts) >= 2 {
|
||||
owner := parts[len(parts)-2]
|
||||
repo := strings.TrimSuffix(parts[len(parts)-1], ".git")
|
||||
if owner != "" && repo != "" {
|
||||
return owner, repo, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("invalid repository format '%s'. Please specify 'owner/repo' (e.g. 'Docker/authentik')", repoArg)
|
||||
}
|
||||
|
||||
// confirmAction prompts the user for yes/no confirmation on stdin.
|
||||
func confirmAction(prompt string) bool {
|
||||
fmt.Printf("%s [y/N]: ", prompt)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
return response == "y" || response == "yes" || response == "j" || response == "ja"
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kmanage/pkg/komodo"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type completionCache struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Repos []string `json:"repos"`
|
||||
Owners []string `json:"owners"`
|
||||
Stacks []string `json:"stacks"`
|
||||
Accounts []string `json:"accounts"`
|
||||
}
|
||||
|
||||
func getCachePath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".kmanage_cache.json"), nil
|
||||
}
|
||||
|
||||
func loadCache() *completionCache {
|
||||
path, err := getCachePath()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var cache completionCache
|
||||
if err := json.Unmarshal(data, &cache); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cache TTL is 3 minutes
|
||||
if time.Since(cache.Timestamp) > 3*time.Minute {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &cache
|
||||
}
|
||||
|
||||
func saveCache(cache *completionCache) {
|
||||
path, err := getCachePath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cache.Timestamp = time.Now()
|
||||
data, _ := json.Marshal(cache)
|
||||
_ = os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
// completeGitAccountNames provides dynamic auto-completion for configured Git account names.
|
||||
func completeGitAccountNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
cfg, err := LoadAppConfig()
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
var accounts []string
|
||||
for k := range cfg.GitAccounts {
|
||||
accounts = append(accounts, k)
|
||||
}
|
||||
|
||||
return filterCompletions(accounts, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// completeRepoNames dynamically suggests repository names for Cobra auto-completion.
|
||||
func completeRepoNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
cached := loadCache()
|
||||
if cached != nil && len(cached.Repos) > 0 {
|
||||
return filterCompletions(cached.Repos, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
accountFlag, _ := cmd.Flags().GetString("account")
|
||||
allAccountsFlag, _ := cmd.Flags().GetBool("all-accounts")
|
||||
|
||||
clients, err := getSelectedGitClients(accountFlag, allAccountsFlag)
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
var repoNames []string
|
||||
ownerMap := make(map[string]bool)
|
||||
|
||||
for _, client := range clients {
|
||||
repos, err := client.ListRepositories()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, r := range repos {
|
||||
repoNames = append(repoNames, r.FullName)
|
||||
owner := r.Owner.UserName
|
||||
if owner == "" && strings.Contains(r.FullName, "/") {
|
||||
owner = strings.SplitN(r.FullName, "/", 2)[0]
|
||||
}
|
||||
if owner != "" {
|
||||
ownerMap[owner] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var ownerNames []string
|
||||
for o := range ownerMap {
|
||||
ownerNames = append(ownerNames, o)
|
||||
}
|
||||
|
||||
cfg, _ := LoadAppConfig()
|
||||
var accountNames []string
|
||||
if cfg != nil {
|
||||
for a := range cfg.GitAccounts {
|
||||
accountNames = append(accountNames, a)
|
||||
}
|
||||
}
|
||||
|
||||
saveCache(&completionCache{
|
||||
Repos: repoNames,
|
||||
Owners: ownerNames,
|
||||
Accounts: accountNames,
|
||||
})
|
||||
|
||||
return filterCompletions(repoNames, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// completeOwnerNames dynamically suggests repository owner/organization names.
|
||||
func completeOwnerNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
cached := loadCache()
|
||||
if cached != nil && len(cached.Owners) > 0 {
|
||||
return filterCompletions(cached.Owners, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
_, _ = completeRepoNames(cmd, args, toComplete)
|
||||
cached = loadCache()
|
||||
if cached != nil {
|
||||
return filterCompletions(cached.Owners, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// completeStackNames dynamically suggests stack names from Komodo.
|
||||
func completeStackNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
client, err := getKomodoClient()
|
||||
if err != nil {
|
||||
url := viper.GetString("komodo_url")
|
||||
key := viper.GetString("komodo_key")
|
||||
secret := viper.GetString("komodo_secret")
|
||||
if url == "" || key == "" || secret == "" {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
client = komodo.NewClient(url, key, secret)
|
||||
}
|
||||
|
||||
stacks, err := client.ListStacks()
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
var stackNames []string
|
||||
for _, s := range stacks {
|
||||
stackNames = append(stackNames, s.Name)
|
||||
}
|
||||
|
||||
return filterCompletions(stackNames, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
func filterCompletions(items []string, prefix string) []string {
|
||||
if prefix == "" {
|
||||
return items
|
||||
}
|
||||
var matches []string
|
||||
lowerPrefix := strings.ToLower(prefix)
|
||||
for _, item := range items {
|
||||
if strings.HasPrefix(strings.ToLower(item), lowerPrefix) {
|
||||
matches = append(matches, item)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kmanage/pkg/secret"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// AppConfig represents the configuration stored in ~/.kmanage.yaml.
|
||||
type AppConfig struct {
|
||||
KomodoURL string `yaml:"komodo_url" mapstructure:"komodo_url"`
|
||||
KomodoKey string `yaml:"komodo_key,omitempty" mapstructure:"komodo_key"` // Keyring fallback only
|
||||
KomodoSecret string `yaml:"komodo_secret,omitempty" mapstructure:"komodo_secret"` // Keyring fallback only
|
||||
DefaultGitAccount string `yaml:"default_git_account" mapstructure:"default_git_account"`
|
||||
GitAccounts map[string]GitAccount `yaml:"git_accounts" mapstructure:"git_accounts"`
|
||||
}
|
||||
|
||||
// GitAccount holds metadata for a configured Git provider account.
|
||||
type GitAccount struct {
|
||||
Type string `yaml:"type" mapstructure:"type"` // gitea, forgejo, github
|
||||
URL string `yaml:"url" mapstructure:"url"`
|
||||
Username string `yaml:"username,omitempty" mapstructure:"username,omitempty"`
|
||||
Token string `yaml:"token,omitempty" mapstructure:"token"` // Keyring fallback only
|
||||
}
|
||||
|
||||
func getConfigFilePath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to determine user home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".kmanage.yaml"), nil
|
||||
}
|
||||
|
||||
// LoadAppConfig loads configuration from ~/.kmanage.yaml.
|
||||
func LoadAppConfig() (*AppConfig, error) {
|
||||
configPath, err := getConfigFilePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &AppConfig{
|
||||
GitAccounts: make(map[string]GitAccount),
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err == nil {
|
||||
_ = yaml.Unmarshal(data, cfg)
|
||||
}
|
||||
|
||||
if cfg.GitAccounts == nil {
|
||||
cfg.GitAccounts = make(map[string]GitAccount)
|
||||
}
|
||||
|
||||
// Automatic migration of legacy single-account configuration (git_url / git_token)
|
||||
oldGitURL := viper.GetString("git_url")
|
||||
if oldGitURL != "" && len(cfg.GitAccounts) == 0 {
|
||||
accountName := "default"
|
||||
accountType := "gitea"
|
||||
if strings.Contains(oldGitURL, "github.com") {
|
||||
accountType = "github"
|
||||
accountName = "github"
|
||||
} else if strings.Contains(oldGitURL, "git.") {
|
||||
accountName = "gitea"
|
||||
}
|
||||
|
||||
oldToken := viper.GetString("git_token")
|
||||
if oldToken != "" {
|
||||
_ = secret.SetSecret("git:"+accountName+":token", oldToken)
|
||||
}
|
||||
|
||||
cfg.GitAccounts[accountName] = GitAccount{
|
||||
Type: accountType,
|
||||
URL: oldGitURL,
|
||||
}
|
||||
if cfg.DefaultGitAccount == "" {
|
||||
cfg.DefaultGitAccount = accountName
|
||||
}
|
||||
_ = saveAppConfig(cfg)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func saveAppConfig(cfg *AppConfig) error {
|
||||
configPath, err := getConfigFilePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize configuration: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write configuration file: %w", err)
|
||||
}
|
||||
|
||||
_ = viper.ReadInConfig()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveKomodoConfig saves the Komodo URL in config and securely stores credentials in the OS keyring.
|
||||
func SaveKomodoConfig(url, key, secretVal string) error {
|
||||
cfg, err := LoadAppConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.KomodoURL = strings.TrimRight(url, "/")
|
||||
cfg.KomodoKey = ""
|
||||
cfg.KomodoSecret = ""
|
||||
|
||||
// Store in OS Keyring
|
||||
if err := secret.SetSecret("komodo:key", key); err != nil {
|
||||
cfg.KomodoKey = key
|
||||
}
|
||||
if err := secret.SetSecret("komodo:secret", secretVal); err != nil {
|
||||
cfg.KomodoSecret = secretVal
|
||||
}
|
||||
|
||||
return saveAppConfig(cfg)
|
||||
}
|
||||
|
||||
// SaveGitAccount saves a Git account configuration and securely stores its token in the OS keyring.
|
||||
func SaveGitAccount(name, accountType, url, token, username string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("account name cannot be empty")
|
||||
}
|
||||
|
||||
cfg, err := LoadAppConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accountType = strings.ToLower(strings.TrimSpace(accountType))
|
||||
if accountType == "" {
|
||||
if strings.Contains(url, "github.com") {
|
||||
accountType = "github"
|
||||
} else {
|
||||
accountType = "gitea"
|
||||
}
|
||||
}
|
||||
|
||||
url = strings.TrimRight(url, "/")
|
||||
if url == "" && accountType == "github" {
|
||||
url = "https://api.github.com"
|
||||
}
|
||||
|
||||
acc := GitAccount{
|
||||
Type: accountType,
|
||||
URL: url,
|
||||
Username: username,
|
||||
}
|
||||
|
||||
keyringKey := "git:" + name + ":token"
|
||||
if err := secret.SetSecret(keyringKey, token); err != nil {
|
||||
acc.Token = token
|
||||
}
|
||||
|
||||
cfg.GitAccounts[name] = acc
|
||||
|
||||
if cfg.DefaultGitAccount == "" || len(cfg.GitAccounts) == 1 {
|
||||
cfg.DefaultGitAccount = name
|
||||
}
|
||||
|
||||
return saveAppConfig(cfg)
|
||||
}
|
||||
|
||||
// SetDefaultGitAccount sets the active default Git account.
|
||||
func SetDefaultGitAccount(name string) error {
|
||||
cfg, err := LoadAppConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, exists := cfg.GitAccounts[name]; !exists {
|
||||
return fmt.Errorf("account '%s' not found. View configured accounts with 'kmanage git accounts'", name)
|
||||
}
|
||||
|
||||
cfg.DefaultGitAccount = name
|
||||
return saveAppConfig(cfg)
|
||||
}
|
||||
|
||||
// DeleteGitAccount deletes a Git account and removes its secret from the OS keyring.
|
||||
func DeleteGitAccount(name string) error {
|
||||
cfg, err := LoadAppConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, exists := cfg.GitAccounts[name]; !exists {
|
||||
return fmt.Errorf("account '%s' not found", name)
|
||||
}
|
||||
|
||||
delete(cfg.GitAccounts, name)
|
||||
_ = secret.DeleteSecret("git:" + name + ":token")
|
||||
|
||||
if cfg.DefaultGitAccount == name {
|
||||
cfg.DefaultGitAccount = ""
|
||||
for k := range cfg.GitAccounts {
|
||||
cfg.DefaultGitAccount = k
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return saveAppConfig(cfg)
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var kmdCmd = &cobra.Command{
|
||||
Use: "kmd",
|
||||
Short: "Komodo management commands",
|
||||
Long: `Commands for interacting directly with the Komodo Core API.`,
|
||||
}
|
||||
|
||||
var kmdConfigureCmd = &cobra.Command{
|
||||
Use: "configure [flags]",
|
||||
Short: "Configure Komodo Core API credentials",
|
||||
Long: `Saves the Komodo URL to ~/.kmanage.yaml and securely stores the API Key and API Secret in the OS keyring.`,
|
||||
Example: ` kmanage kmd configure --url https://kmd.example.com --key K_..._K --secret S_..._S`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
url, _ := cmd.Flags().GetString("url")
|
||||
key, _ := cmd.Flags().GetString("key")
|
||||
secret, _ := cmd.Flags().GetString("secret")
|
||||
|
||||
if url == "" || key == "" || secret == "" {
|
||||
_ = cmd.Help()
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return fmt.Errorf("❌ Error: The flags '--url', '--key', and '--secret' are all required")
|
||||
}
|
||||
|
||||
if err := SaveKomodoConfig(url, key, secret); err != nil {
|
||||
return fmt.Errorf("failed to save Komodo configuration: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("✅ Komodo configuration successfully saved (credentials stored in OS keyring).")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(kmdCmd)
|
||||
kmdCmd.AddCommand(kmdConfigureCmd)
|
||||
|
||||
kmdConfigureCmd.Flags().StringP("url", "u", "", "Komodo API Base URL (e.g. https://kmd.example.com)")
|
||||
kmdConfigureCmd.Flags().StringP("key", "k", "", "Komodo API Key (required)")
|
||||
kmdConfigureCmd.Flags().StringP("secret", "s", "", "Komodo API Secret (required)")
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var kmdListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all stacks from Komodo Core",
|
||||
Long: `Retrieves all stacks from Komodo along with their runtime status, git provider, repo, and webhook configuration.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := getKomodoClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stacks, err := client.ListStacksWithDetails()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve stacks from Komodo: %w", err)
|
||||
}
|
||||
|
||||
if len(stacks) == 0 {
|
||||
fmt.Println("No stacks found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
|
||||
noHeader, _ := cmd.Flags().GetBool("no-header")
|
||||
if !noHeader {
|
||||
fmt.Fprintln(w, "NAME\tSERVER\tSTATUS\tPROVIDER\tREPO\tWEBHOOK")
|
||||
fmt.Fprintln(w, "----\t------\t------\t--------\t----\t-------")
|
||||
}
|
||||
|
||||
for _, s := range stacks {
|
||||
webhookStatus := "disabled"
|
||||
if s.WebhookEnabled {
|
||||
webhookStatus = "enabled"
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
s.Name,
|
||||
s.Server,
|
||||
s.Status,
|
||||
s.GitProvider,
|
||||
s.Repo,
|
||||
webhookStatus,
|
||||
)
|
||||
}
|
||||
|
||||
return w.Flush()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
kmdCmd.AddCommand(kmdListCmd)
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "kmanage",
|
||||
Short: "kmanage - CLI management tool for Komodo stacks and Git infrastructure",
|
||||
Long: `kmanage is a fast, lightweight CLI tool for managing Komodo Core stacks,
|
||||
Git repositories, webhooks, and pull-request branch automation across multiple Git providers.`,
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
// Silence usage text on runtime execution errors once flags have been validated
|
||||
cmd.SilenceUsage = true
|
||||
},
|
||||
}
|
||||
|
||||
// Execute executes the root cobra command.
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
rootCmd.PersistentFlags().Bool("no-header", false, "Omit table headers from tabular output")
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
viper.AddConfigPath(home)
|
||||
viper.SetConfigType("yaml")
|
||||
viper.SetConfigName(".kmanage")
|
||||
|
||||
viper.SetEnvPrefix("KMANAGE")
|
||||
viper.AutomaticEnv()
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
// Config file might not exist yet
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
module kmanage
|
||||
|
||||
go 1.26.6
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/zalando/go-keyring v0.2.8 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
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/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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
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/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "kmanage/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProviderType represents the type of git provider.
|
||||
type ProviderType string
|
||||
|
||||
const (
|
||||
ProviderGitea ProviderType = "gitea"
|
||||
ProviderForgejo ProviderType = "forgejo"
|
||||
ProviderGitHub ProviderType = "github"
|
||||
)
|
||||
|
||||
// Repository represents a git repository compatible across Gitea, Forgejo, and GitHub APIs.
|
||||
type Repository struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
Fork bool `json:"fork"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
SSHURL string `json:"ssh_url"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
DefaultDeleteBranchAfterMerge bool `json:"default_delete_branch_after_merge"`
|
||||
DeleteBranchOnMerge bool `json:"delete_branch_on_merge"`
|
||||
StarsCount int `json:"stargazers_count"`
|
||||
ForksCount int `json:"forks_count"`
|
||||
OpenIssues int `json:"open_issues_count"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Owner User `json:"owner"`
|
||||
AccountName string `json:"-"`
|
||||
}
|
||||
|
||||
// IsAutoDeleteBranch returns true if automatic branch deletion after pull request merge is enabled.
|
||||
func (r *Repository) IsAutoDeleteBranch() bool {
|
||||
return r.DefaultDeleteBranchAfterMerge || r.DeleteBranchOnMerge
|
||||
}
|
||||
|
||||
// User represents a repository owner or user account.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
UserName string `json:"login"`
|
||||
}
|
||||
|
||||
// Hook represents a configured webhook.
|
||||
type Hook struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Config map[string]string `json:"config"`
|
||||
Events []string `json:"events"`
|
||||
Active bool `json:"active"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TargetURL returns the webhook target endpoint URL.
|
||||
func (h *Hook) TargetURL() string {
|
||||
if h.Config != nil {
|
||||
if u, ok := h.Config["url"]; ok && u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return h.URL
|
||||
}
|
||||
|
||||
// RepositoryWithHooks aggregates a repository along with its configured webhooks.
|
||||
type RepositoryWithHooks struct {
|
||||
Repository
|
||||
Hooks []Hook
|
||||
}
|
||||
|
||||
// Client interacts with Git providers (Gitea, Forgejo, GitHub).
|
||||
type Client struct {
|
||||
AccountName string
|
||||
BaseURL string
|
||||
Token string
|
||||
ProviderType ProviderType
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new Git client instance.
|
||||
func NewClient(accountName, baseURL, token string, providerType ProviderType) *Client {
|
||||
cleanURL := strings.TrimRight(baseURL, "/")
|
||||
if cleanURL == "" {
|
||||
if providerType == ProviderGitHub {
|
||||
cleanURL = "https://api.github.com"
|
||||
}
|
||||
}
|
||||
if providerType == "" {
|
||||
if strings.Contains(cleanURL, "github.com") {
|
||||
providerType = ProviderGitHub
|
||||
} else {
|
||||
providerType = ProviderGitea
|
||||
}
|
||||
}
|
||||
|
||||
return &Client{
|
||||
AccountName: accountName,
|
||||
BaseURL: cleanURL,
|
||||
Token: token,
|
||||
ProviderType: providerType,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) setAuthHeaders(req *http.Request) {
|
||||
if c.Token != "" {
|
||||
if c.ProviderType == ProviderGitHub {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
} else {
|
||||
req.Header.Set("Authorization", "token "+c.Token)
|
||||
}
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
|
||||
// ListRepositories retrieves all repositories accessible by the configured account.
|
||||
func (c *Client) ListRepositories() ([]Repository, error) {
|
||||
var allRepos []Repository
|
||||
page := 1
|
||||
limit := 100
|
||||
|
||||
for {
|
||||
var url string
|
||||
if c.ProviderType == ProviderGitHub {
|
||||
url = fmt.Sprintf("%s/user/repos?per_page=%d&page=%d&affiliation=owner,collaborator,organization_member", c.BaseURL, limit, page)
|
||||
} else {
|
||||
url = fmt.Sprintf("%s/api/v1/user/repos?limit=%d&page=%d", c.BaseURL, limit, page)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create http request: %w", err)
|
||||
}
|
||||
|
||||
c.setAuthHeaders(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("git api error (status %s): %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
var pageRepos []Repository
|
||||
if err := json.Unmarshal(body, &pageRepos); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse json response (%w): %s", err, string(body))
|
||||
}
|
||||
|
||||
if len(pageRepos) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for i := range pageRepos {
|
||||
pageRepos[i].AccountName = c.AccountName
|
||||
}
|
||||
|
||||
allRepos = append(allRepos, pageRepos...)
|
||||
|
||||
if len(pageRepos) < limit {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return allRepos, nil
|
||||
}
|
||||
|
||||
// ListHooks retrieves all configured webhooks for a repository.
|
||||
func (c *Client) ListHooks(owner, repo string) ([]Hook, error) {
|
||||
var url string
|
||||
if c.ProviderType == ProviderGitHub {
|
||||
url = fmt.Sprintf("%s/repos/%s/%s/hooks", c.BaseURL, owner, repo)
|
||||
} else {
|
||||
url = fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks", c.BaseURL, owner, repo)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create webhook request: %w", err)
|
||||
}
|
||||
|
||||
c.setAuthHeaders(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve webhooks: %w", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read webhook response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("webhook api error (status %s): %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
var hooks []Hook
|
||||
if err := json.Unmarshal(body, &hooks); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse webhook json response: %w", err)
|
||||
}
|
||||
|
||||
return hooks, nil
|
||||
}
|
||||
|
||||
// ListRepositoriesWithHooks retrieves all repositories and concurrently fetches their webhooks.
|
||||
func (c *Client) ListRepositoriesWithHooks() ([]RepositoryWithHooks, error) {
|
||||
repos, err := c.ListRepositories()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]RepositoryWithHooks, len(repos))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, 10)
|
||||
|
||||
for i, r := range repos {
|
||||
wg.Add(1)
|
||||
go func(idx int, repo Repository) {
|
||||
defer wg.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
ownerName := repo.Owner.UserName
|
||||
if ownerName == "" && strings.Contains(repo.FullName, "/") {
|
||||
ownerName = strings.SplitN(repo.FullName, "/", 2)[0]
|
||||
}
|
||||
|
||||
repoName := repo.Name
|
||||
if repoName == "" && strings.Contains(repo.FullName, "/") {
|
||||
repoName = strings.SplitN(repo.FullName, "/", 2)[1]
|
||||
}
|
||||
|
||||
hooks, err := c.ListHooks(ownerName, repoName)
|
||||
if err != nil {
|
||||
hooks = []Hook{}
|
||||
}
|
||||
|
||||
results[idx] = RepositoryWithHooks{
|
||||
Repository: repo,
|
||||
Hooks: hooks,
|
||||
}
|
||||
}(i, r)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// DeleteHook deletes a single webhook by ID.
|
||||
func (c *Client) DeleteHook(owner, repo string, hookID int64) error {
|
||||
var url string
|
||||
if c.ProviderType == ProviderGitHub {
|
||||
url = fmt.Sprintf("%s/repos/%s/%s/hooks/%d", c.BaseURL, owner, repo, hookID)
|
||||
} else {
|
||||
url = fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks/%d", c.BaseURL, owner, repo, hookID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("DELETE", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create delete request: %w", err)
|
||||
}
|
||||
|
||||
c.setAuthHeaders(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute delete request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("delete failed (status %s): %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAutoDeleteBranch enables or disables automatic pull request branch deletion on merge.
|
||||
func (c *Client) SetAutoDeleteBranch(owner, repo string, enable bool) (*Repository, error) {
|
||||
var url string
|
||||
var payload map[string]interface{}
|
||||
|
||||
if c.ProviderType == ProviderGitHub {
|
||||
url = fmt.Sprintf("%s/repos/%s/%s", c.BaseURL, owner, repo)
|
||||
payload = map[string]interface{}{
|
||||
"delete_branch_on_merge": enable,
|
||||
}
|
||||
} else {
|
||||
url = fmt.Sprintf("%s/api/v1/repos/%s/%s", c.BaseURL, owner, repo)
|
||||
payload = map[string]interface{}{
|
||||
"default_delete_branch_after_merge": enable,
|
||||
}
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize options: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create patch request: %w", err)
|
||||
}
|
||||
|
||||
c.setAuthHeaders(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute patch request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("repository update failed (status %s): %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
var updatedRepo Repository
|
||||
if err := json.Unmarshal(body, &updatedRepo); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
updatedRepo.AccountName = c.AccountName
|
||||
|
||||
return &updatedRepo, nil
|
||||
}
|
||||
|
||||
// CreateRepoOptions defines parameters for creating a new repository.
|
||||
type CreateRepoOptions struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
AutoInit bool `json:"auto_init"`
|
||||
DefaultBranch string `json:"default_branch,omitempty"`
|
||||
Org string `json:"-"`
|
||||
}
|
||||
|
||||
// CreateRepository creates a new repository on the Git provider under the user or an organization.
|
||||
func (c *Client) CreateRepository(opt CreateRepoOptions) (*Repository, error) {
|
||||
var url string
|
||||
if c.ProviderType == ProviderGitHub {
|
||||
if opt.Org != "" {
|
||||
url = fmt.Sprintf("%s/orgs/%s/repos", c.BaseURL, opt.Org)
|
||||
} else {
|
||||
url = fmt.Sprintf("%s/user/repos", c.BaseURL)
|
||||
}
|
||||
} else {
|
||||
if opt.Org != "" {
|
||||
url = fmt.Sprintf("%s/api/v1/orgs/%s/repos", c.BaseURL, opt.Org)
|
||||
} else {
|
||||
url = fmt.Sprintf("%s/api/v1/user/repos", c.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(opt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize create repository payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create http request: %w", err)
|
||||
}
|
||||
|
||||
c.setAuthHeaders(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute create repository request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("repository creation failed (status %s): %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
var createdRepo Repository
|
||||
if err := json.Unmarshal(body, &createdRepo); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse created repository response: %w", err)
|
||||
}
|
||||
createdRepo.AccountName = c.AccountName
|
||||
|
||||
return &createdRepo, nil
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package komodo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RPCRequest represents the Komodo JSON-RPC request format.
|
||||
type RPCRequest struct {
|
||||
Type string `json:"type"`
|
||||
Params interface{} `json:"params"`
|
||||
}
|
||||
|
||||
// StackListItem represents summary information for a stack returned by ListStacks.
|
||||
type StackListItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
Template bool `json:"template"`
|
||||
Tags []string `json:"tags"`
|
||||
Info StackListItemInfo `json:"info"`
|
||||
}
|
||||
|
||||
// StackListItemInfo contains status, runtime, and git details of a stack.
|
||||
type StackListItemInfo struct {
|
||||
ServerID string `json:"server_id"`
|
||||
ServerName string `json:"server_name"`
|
||||
SwarmID string `json:"swarm_id"`
|
||||
SwarmName string `json:"swarm_name"`
|
||||
State string `json:"state"`
|
||||
Status *string `json:"status"`
|
||||
Services []StackService `json:"services"`
|
||||
Repo string `json:"repo"`
|
||||
Branch string `json:"branch"`
|
||||
RepoLink string `json:"repo_link"`
|
||||
GitProvider string `json:"git_provider"`
|
||||
DeployedHash *string `json:"deployed_hash"`
|
||||
LatestHash *string `json:"latest_hash"`
|
||||
ProjectMissing bool `json:"project_missing"`
|
||||
}
|
||||
|
||||
// StackService represents a service inside a stack.
|
||||
type StackService struct {
|
||||
Service string `json:"service"`
|
||||
Image string `json:"image"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// Stack represents the complete stack entity returned by GetStack.
|
||||
type Stack struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Template bool `json:"template"`
|
||||
Tags []string `json:"tags"`
|
||||
Config StackConfig `json:"config"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// StackConfig contains configuration parameters of a stack.
|
||||
type StackConfig struct {
|
||||
ServerID string `json:"server_id"`
|
||||
SwarmID string `json:"swarm_id"`
|
||||
GitProvider string `json:"git_provider"`
|
||||
GitAccount string `json:"git_account"`
|
||||
Repo string `json:"repo"`
|
||||
Branch string `json:"branch"`
|
||||
Commit string `json:"commit"`
|
||||
WebhookEnabled bool `json:"webhook_enabled"`
|
||||
WebhookSecret string `json:"webhook_secret"`
|
||||
WebhookForceDeploy bool `json:"webhook_force_deploy"`
|
||||
AutoPull bool `json:"auto_pull"`
|
||||
AutoUpdate bool `json:"auto_update"`
|
||||
AutoUpdateAllServices bool `json:"auto_update_all_services"`
|
||||
FilesOnHost bool `json:"files_on_host"`
|
||||
RunDirectory string `json:"run_directory"`
|
||||
EnvFilePath string `json:"env_file_path"`
|
||||
}
|
||||
|
||||
// StackRow aggregates stack information formatted for tabular display.
|
||||
type StackRow struct {
|
||||
ID string
|
||||
Name string
|
||||
Server string
|
||||
Status string
|
||||
GitProvider string
|
||||
Repo string
|
||||
Branch string
|
||||
WebhookEnabled bool
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// Client is the client for the Komodo Core API.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
APISecret string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new Komodo Client with trimmed URL and standard timeout.
|
||||
func NewClient(baseURL, apiKey, apiSecret string) *Client {
|
||||
cleanURL := strings.TrimRight(baseURL, "/")
|
||||
return &Client{
|
||||
BaseURL: cleanURL,
|
||||
APIKey: apiKey,
|
||||
APISecret: apiSecret,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Read executes a POST /read RPC request against the Komodo API.
|
||||
func (c *Client) Read(reqType string, params interface{}, result interface{}) error {
|
||||
url := fmt.Sprintf("%s/read", c.BaseURL)
|
||||
|
||||
if params == nil {
|
||||
params = map[string]interface{}{}
|
||||
}
|
||||
|
||||
payload := RPCRequest{
|
||||
Type: reqType,
|
||||
Params: params,
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize request payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create http request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("X-Api-Key", c.APIKey)
|
||||
req.Header.Set("X-Api-Secret", c.APISecret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send api request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("komodo api error (status %s): %s", resp.Status, string(respBody))
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("failed to parse json response (%w): %s", err, string(respBody))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListStacks retrieves all stacks from the Komodo Core API.
|
||||
func (c *Client) ListStacks() ([]StackListItem, error) {
|
||||
var stacks []StackListItem
|
||||
err := c.Read("ListStacks", map[string]interface{}{}, &stacks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stacks, nil
|
||||
}
|
||||
|
||||
// GetStack retrieves full entity details for a specific stack ID or name.
|
||||
func (c *Client) GetStack(stackIDOrName string) (*Stack, error) {
|
||||
var stack Stack
|
||||
err := c.Read("GetStack", map[string]interface{}{"stack": stackIDOrName}, &stack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &stack, nil
|
||||
}
|
||||
|
||||
// ListStacksWithDetails fetches all stacks and concurrently resolves their full configurations (e.g. webhook status).
|
||||
func (c *Client) ListStacksWithDetails() ([]StackRow, error) {
|
||||
stacks, err := c.ListStacks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]StackRow, len(stacks))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, 10) // max 10 concurrent requests
|
||||
|
||||
for i, s := range stacks {
|
||||
wg.Add(1)
|
||||
go func(idx int, item StackListItem) {
|
||||
defer wg.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
server := item.Info.ServerName
|
||||
if server == "" {
|
||||
server = item.Info.SwarmName
|
||||
}
|
||||
if server == "" {
|
||||
server = "-"
|
||||
}
|
||||
|
||||
status := item.Info.State
|
||||
if status == "" {
|
||||
status = "-"
|
||||
}
|
||||
|
||||
provider := item.Info.GitProvider
|
||||
if provider == "" {
|
||||
provider = "-"
|
||||
}
|
||||
|
||||
repo := item.Info.Repo
|
||||
if repo == "" {
|
||||
repo = "-"
|
||||
}
|
||||
|
||||
row := StackRow{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Server: server,
|
||||
Status: status,
|
||||
GitProvider: provider,
|
||||
Repo: repo,
|
||||
Branch: item.Info.Branch,
|
||||
WebhookEnabled: false,
|
||||
Tags: item.Tags,
|
||||
}
|
||||
|
||||
stackDetail, err := c.GetStack(item.ID)
|
||||
if err == nil && stackDetail != nil {
|
||||
row.WebhookEnabled = stackDetail.Config.WebhookEnabled
|
||||
if stackDetail.Config.GitProvider != "" {
|
||||
row.GitProvider = stackDetail.Config.GitProvider
|
||||
}
|
||||
if stackDetail.Config.Repo != "" {
|
||||
row.Repo = stackDetail.Config.Repo
|
||||
}
|
||||
if stackDetail.Config.Branch != "" {
|
||||
row.Branch = stackDetail.Config.Branch
|
||||
}
|
||||
}
|
||||
|
||||
results[idx] = row
|
||||
}(i, s)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// ServiceName is the service identifier used in the OS keyring.
|
||||
const ServiceName = "kmanage"
|
||||
|
||||
// SetSecret securely stores a key-value secret in the operating system keyring (macOS Keychain, Windows Credential Manager, Linux SecretService).
|
||||
func SetSecret(key, value string) error {
|
||||
if key == "" {
|
||||
return fmt.Errorf("secret key cannot be empty")
|
||||
}
|
||||
return keyring.Set(ServiceName, key, value)
|
||||
}
|
||||
|
||||
// GetSecret retrieves a secret from environment variables or the operating system keyring.
|
||||
func GetSecret(key string) (string, error) {
|
||||
// 1. Priority: Direct environment variable (e.g. KMANAGE_KOMODO_KEY or KMANAGE_GIT_GITEA_TOKEN)
|
||||
envKey := "KMANAGE_" + strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(key, ":", "_"), "-", "_"))
|
||||
if val := os.Getenv(envKey); val != "" {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// 2. Priority: OS Keyring
|
||||
val, err := keyring.Get(ServiceName, key)
|
||||
if err == nil && val != "" {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
// DeleteSecret removes a secret from the operating system keyring.
|
||||
func DeleteSecret(key string) error {
|
||||
return keyring.Delete(ServiceName, key)
|
||||
}
|
||||
Reference in New Issue
Block a user