Initial commit created by kmanage

This commit is contained in:
2026-08-29 01:08:12 +02:00
commit ecc791d710
19 changed files with 2933 additions and 0 deletions
View File
+330
View File
@@ -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
View File
@@ -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)
}
+222
View File
@@ -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)
}
+215
View File
@@ -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)
}
}
+366
View File
@@ -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)
}
+151
View File
@@ -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"
}
+197
View File
@@ -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
}
+215
View File
@@ -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
View File
@@ -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)")
}
+61
View File
@@ -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
View File
@@ -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
}
}
+28
View File
@@ -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
)
+60
View File
@@ -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=
Executable
BIN
View File
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
package main
import "kmanage/cmd"
func main() {
cmd.Execute()
}
+423
View File
@@ -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
}
+264
View File
@@ -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
}
+42
View File
@@ -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)
}