commit ecc791d710e04814bfaf59e0fb0a55a268b14446 Author: Matthias Hinrichs Date: Sat Aug 29 01:08:12 2026 +0200 Initial commit created by kmanage diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec4fd6a --- /dev/null +++ b/README.md @@ -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 `` 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 --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 + +# Add a GitHub account: +kmanage git configure --name github --type github --token + +# Add an additional work or organization account: +kmanage git configure --name work --url https://git.company.com --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 +``` diff --git a/cmd/git.go b/cmd/git.go new file mode 100644 index 0000000..685405b --- /dev/null +++ b/cmd/git.go @@ -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 --url --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 ", + 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 ", + 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) +} diff --git a/cmd/git_init_repo.go b/cmd/git_init_repo.go new file mode 100644 index 0000000..cdfc610 --- /dev/null +++ b/cmd/git_init_repo.go @@ -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) +} diff --git a/cmd/git_pr_autodel.go b/cmd/git_pr_autodel.go new file mode 100644 index 0000000..4337a57 --- /dev/null +++ b/cmd/git_pr_autodel.go @@ -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 ' / '--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) + } +} diff --git a/cmd/git_webhook.go b/cmd/git_webhook.go new file mode 100644 index 0000000..2edad53 --- /dev/null +++ b/cmd/git_webhook.go @@ -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 ", + 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 ", + 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 (single webhook)\n --all (all webhooks in repo)\n --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) +} diff --git a/cmd/helper_clients.go b/cmd/helper_clients.go new file mode 100644 index 0000000..edaa2b2 --- /dev/null +++ b/cmd/helper_clients.go @@ -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 --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 '", 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 --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" +} diff --git a/cmd/helper_completion.go b/cmd/helper_completion.go new file mode 100644 index 0000000..862e3ff --- /dev/null +++ b/cmd/helper_completion.go @@ -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 +} diff --git a/cmd/helper_config.go b/cmd/helper_config.go new file mode 100644 index 0000000..e1a8266 --- /dev/null +++ b/cmd/helper_config.go @@ -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) +} diff --git a/cmd/kmd.go b/cmd/kmd.go new file mode 100644 index 0000000..d0b2caf --- /dev/null +++ b/cmd/kmd.go @@ -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)") +} diff --git a/cmd/kmd_list.go b/cmd/kmd_list.go new file mode 100644 index 0000000..9705386 --- /dev/null +++ b/cmd/kmd_list.go @@ -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) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..9febc41 --- /dev/null +++ b/cmd/root.go @@ -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 + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..563f31f --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ce655c4 --- /dev/null +++ b/go.sum @@ -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= diff --git a/kmanage b/kmanage new file mode 100755 index 0000000..4b3db66 Binary files /dev/null and b/kmanage differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..fdd88e3 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "kmanage/cmd" + +func main() { + cmd.Execute() +} diff --git a/pkg/git/client.go b/pkg/git/client.go new file mode 100644 index 0000000..1ce21c5 --- /dev/null +++ b/pkg/git/client.go @@ -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 +} diff --git a/pkg/komodo/client.go b/pkg/komodo/client.go new file mode 100644 index 0000000..4bc019c --- /dev/null +++ b/pkg/komodo/client.go @@ -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 +} diff --git a/pkg/secret/secret.go b/pkg/secret/secret.go new file mode 100644 index 0000000..de8437c --- /dev/null +++ b/pkg/secret/secret.go @@ -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) +}