diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 36d6666..90f2c64 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,7 +60,7 @@ jobs: ext="" if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi out="dist/kctl-tui-${{ matrix.goos }}-${{ matrix.goarch }}${ext}" - go build -o "$out" -ldflags "-s -w" ./cmd/kctl-tui + go build -o "$out" -ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" ./cmd/kctl-tui echo "Built $out" - name: Upload artifact diff --git a/PLAN.md b/PLAN.md index a769502..4cb3056 100644 --- a/PLAN.md +++ b/PLAN.md @@ -76,11 +76,11 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md). ## Phase 4 — Nice-to-haves (open, not committed) +- [x] `--version` flag — prints version, set via `-ldflags` at build time. +- [x] Config validation command (`kctl-tui config check`) — validates + required fields and shows a resolved context example. - [ ] Optional direct use of `client-go` instead of shelling out to `kubectl`, for faster context/namespace/label queries. -- [ ] Config validation command (`kctl-tui config check`) that reports - unknown label keys or context names not present in the current - kubeconfig. - [ ] Homebrew tap / `scoop` manifest as additional install options alongside `install.sh`. diff --git a/README.md b/README.md index 1feabcf..2f41422 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,16 @@ mkdir -p ~/.kube ln -s /mnt/c/Users//.kube/config ~/.kube/config ``` +## Usage + +```bash +kctl-tui # start the TUI (full navigation mode) +kctl-tui --version # print version +kctl-tui --verbose # enable debug logging to stderr +kctl-tui config check # validate ~/.kctl-tui/config.yaml +kctl-tui panel --context=... --ns=... --team=... # internal (called by tmux) +``` + ## Development ```bash diff --git a/cmd/kctl-tui/full.go b/cmd/kctl-tui/full.go index c928555..e5f8297 100644 --- a/cmd/kctl-tui/full.go +++ b/cmd/kctl-tui/full.go @@ -264,44 +264,41 @@ func (m *fullModel) startTmuxSession() tea.Cmd { if err := kubeexec.CheckTool("k9s"); err != nil { return errMsg{err} } - return m.buildAndRunTmux() - } -} -func (m *fullModel) buildAndRunTmux() tea.Msg { - selfPath := "kctl-tui" // resolved via PATH; see README for install instructions - panelCmd := fmt.Sprintf("%s panel --context=%s --ns=%s --team=%s", - selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam) + selfPath := "kctl-tui" // resolved via PATH; see README for install instructions + panelCmd := fmt.Sprintf("%s panel --context=%s --ns=%s --team=%s", + selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam) - envA := m.cfg.Envs[0] - ctxA := m.cfg.ResolveContext(envA, m.selectedContext) - k9sCmdA := fmt.Sprintf("k9s --context %s -n %s", ctxA, m.selectedNamespace) + envA := m.cfg.Envs[0] + ctxA := m.cfg.ResolveContext(envA, m.selectedContext) + k9sCmdA := fmt.Sprintf("k9s --context %s -n %s", ctxA, m.selectedNamespace) - args := []string{ - "new-session", "-d", "-s", "kctl", - panelCmd, ";", - "set-option", "-t", "kctl", "remain-on-exit", "on", ";", - "split-window", "-v", "-t", "kctl:0.0", k9sCmdA, ";", - } - if len(m.cfg.Envs) > 1 { - envB := m.cfg.Envs[1] - ctxB := m.cfg.ResolveContext(envB, m.selectedContext) - k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", ctxB, m.selectedNamespace) + args := []string{ + "new-session", "-d", "-s", "kctl", + panelCmd, ";", + "set-option", "-t", "kctl", "remain-on-exit", "on", ";", + "split-window", "-v", "-t", "kctl:0.0", k9sCmdA, ";", + } + if len(m.cfg.Envs) > 1 { + envB := m.cfg.Envs[1] + ctxB := m.cfg.ResolveContext(envB, m.selectedContext) + k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", ctxB, m.selectedNamespace) + args = append(args, + "split-window", "-v", "-t", "kctl:0.1", k9sCmdB, ";", + ) + } args = append(args, - "split-window", "-v", "-t", "kctl:0.1", k9sCmdB, ";", + "select-layout", "-t", "kctl", "even-vertical", ";", + "select-pane", "-t", "kctl:0.0", ";", + "attach", "-t", "kctl", ) + + c := exec.Command("tmux", args...) + + return tea.ExecProcess(c, func(err error) tea.Msg { + return tmuxDoneMsg{err: err} + }) } - args = append(args, - "select-layout", "-t", "kctl", "even-vertical", ";", - "select-pane", "-t", "kctl:0.0", ";", - "attach", "-t", "kctl", - ) - - c := exec.Command("tmux", args...) - - return tea.ExecProcess(c, func(err error) tea.Msg { - return tmuxDoneMsg{err: err} - }) } func (m *fullModel) View() string { diff --git a/cmd/kctl-tui/main.go b/cmd/kctl-tui/main.go index 5fba0c3..3919187 100644 --- a/cmd/kctl-tui/main.go +++ b/cmd/kctl-tui/main.go @@ -6,19 +6,27 @@ import ( tea "github.com/charmbracelet/bubbletea" + "github.com/skoelle/kctl-tui/internal/config" "github.com/skoelle/kctl-tui/internal/kubeexec" ) +// version is set via -ldflags at build time. +var version = "dev" + func main() { args := os.Args[1:] - // Extract --verbose before delegating to panel or full mode. + // Extract global flags before delegating to sub-commands. verbose := false filtered := make([]string, 0, len(args)) for _, a := range args { - if a == "--verbose" { + switch a { + case "--verbose": verbose = true - } else { + case "--version", "-v": + fmt.Printf("kctl-tui %s\n", version) + return + default: filtered = append(filtered, a) } } @@ -26,12 +34,21 @@ func main() { kubeexec.SetVerbose(true, os.Stderr) } - if len(filtered) > 0 && filtered[0] == "panel" { - if err := runPanel(filtered[1:]); err != nil { - fmt.Fprintln(os.Stderr, "kctl-tui panel error:", err) - os.Exit(1) + if len(filtered) > 0 { + switch filtered[0] { + case "panel": + if err := runPanel(filtered[1:]); err != nil { + fmt.Fprintln(os.Stderr, "kctl-tui panel error:", err) + os.Exit(1) + } + return + case "config": + if err := runConfig(filtered[1:]); err != nil { + fmt.Fprintln(os.Stderr, "kctl-tui config error:", err) + os.Exit(1) + } + return } - return } m := newFullModel() @@ -41,3 +58,55 @@ func main() { os.Exit(1) } } + +func runConfig(args []string) error { + if len(args) == 0 || args[0] != "check" { + return fmt.Errorf("usage: kctl-tui config check") + } + cfgPath, err := config.DefaultPath() + if err != nil { + return fmt.Errorf("cannot determine config path: %w", err) + } + cfg, err := config.Load(cfgPath) + if err != nil { + return fmt.Errorf("failed to load %s: %w", cfgPath, err) + } + + ok := true + if len(cfg.Contexts) == 0 { + fmt.Fprintln(os.Stderr, "ERROR: no 'contexts' configured") + ok = false + } + if len(cfg.Envs) == 0 { + fmt.Fprintln(os.Stderr, "ERROR: no 'envs' configured") + ok = false + } + if cfg.ContextTemplate == "" { + fmt.Fprintln(os.Stderr, "ERROR: 'context_template' is empty") + ok = false + } + if cfg.SecretNameTemplate == "" { + fmt.Fprintln(os.Stderr, "ERROR: 'secret_name_template' is empty") + ok = false + } + if cfg.TeamLabelKey == "" { + fmt.Fprintln(os.Stderr, "WARNING: 'team_label_key' is empty — team selection will have no groups") + } + if cfg.AWSRegion == "" { + fmt.Fprintln(os.Stderr, "WARNING: 'aws_region' is empty — secrets workflow will fail") + } + + // Try resolving one context to verify the template works. + if len(cfg.Contexts) > 0 && len(cfg.Envs) > 0 && cfg.ContextTemplate != "" { + ctx := cfg.ResolveContext(cfg.Envs[0], cfg.Contexts[0]) + fmt.Printf("Resolved context example: %s\n", ctx) + } + + if ok { + fmt.Println("Config OK") + } else { + fmt.Fprintln(os.Stderr, "Config has errors — see above") + os.Exit(1) + } + return nil +}