Compare commits

...
37 Commits
Author SHA1 Message Date
stefankoelle 10f996cfef release v0.2.1
This release adds --help and --version flags, updates dependencies,
improves documentation for Windows support, and refines CI pipelines.

- Add --help and --version flags to main binary
- Update GitHub Actions workflow to latest major versions
- Update Go dependencies to latest versions
- Fix SPEC.md and README.md for current codebase (Windows fully supported)
- Update PLAN.md with roadmap for v0.3.0 and v1.0
- Add release command automation
2026-08-11 14:08:27 +02:00
stefankoelle 15e4aa64f7 create release command fix 2026-08-11 14:06:00 +02:00
stefankoelle 50ad7d7335 create release command 2026-08-11 14:04:59 +02:00
stefankoelle 28eeeda58e Merge remote-tracking branch 'origin/main' 2026-08-11 13:08:38 +02:00
stefankoelle 0d33a57118 fix 2026-08-11 13:06:18 +02:00
Stefan Koelle 0dbce4769b Merge pull request #2 from skoelle/renovate/major-github-actions-(major)
Update GitHub Actions (major) (major)
2026-08-11 12:43:46 +02:00
Stefan Koelle 406d65ca33 Merge pull request #1 from skoelle/renovate/go-dependencies
Update Go dependencies
2026-08-11 12:43:36 +02:00
renovate[bot] d066733c20 Update GitHub Actions (major) 2026-08-11 10:41:01 +00:00
renovate[bot] 2e7d48a2bb Update Go dependencies 2026-08-11 10:40:54 +00:00
stefankoelle be4ad209db renovate update 2026-08-11 12:36:02 +02:00
stefankoelle d8d656504c --help and --version update 2026-08-11 12:31:39 +02:00
stefankoelle a3480f41bf renovate 2026-08-11 12:24:31 +02:00
stefankoelle 9521f47377 Fix SPEC.md and README.md for current codebase
- SPEC.md: Windows is now a fully supported platform (not secondary)
- SPEC.md: Remove outdated 'manual pane handling' claim for Windows
- SPEC.md: Update non-functional requirements (Linux/macOS/Windows)
- README.md: Remove duplicate 'This downloads...' paragraph
- README.md: Use --namespace instead of -n in k9s examples
2026-08-09 22:32:27 +02:00
stefankoelle a1e8894222 Add security, CI, and tooling items to bugfix sprint 2026-08-09 22:30:34 +02:00
stefankoelle 43aef3b76c Add bugfix sprint to PLAN.md between v0.2.0 and v0.3.0 2026-08-09 22:29:13 +02:00
stefankoelle 84dbd9c230 Update PLAN.md: mark v0.2.0 done, add roadmap for v0.3.0 and v1.0 2026-08-09 22:27:26 +02:00
stefankoelle 2911f97991 Add CHANGELOG, CONTRIBUTING, and GitHub Issue/PR templates for v0.2.0 2026-08-09 22:13:09 +02:00
stefankoelle 7db58b6fe8 Disable Bubbles list filter to prevent stuck filter state
The / filter in the list component captured keystrokes after returning
from tmux, showing random text like 'cc' and blocking cursor navigation.
Filtering is not needed for the 3-level context/team/namespace flow.
2026-08-09 22:07:49 +02:00
stefankoelle 201168e8cd Fix k9s on Windows: --namespace, --command pods
- -n flag not supported on Windows k9s, use --namespace instead
- Start k9s directly in pods view with --command pods
2026-08-09 21:57:24 +02:00
stefankoelle ffbf4341fa Remove debug output from TUI, use verbose-only logging for tmux setup
- Debug fmt.Fprintf calls were always visible in TUI
- Added VerboseLog() to kubeexec package for use by full.go
- All tmux debug output now only shows with --verbose flag
2026-08-09 21:46:25 +02:00
stefankoelle 37be0707ad Fix Windows: run tmux setup commands individually, attach via ExecProcess
psmux on Windows doesn't handle ; separators when args are passed
individually via exec.Command. Instead of chaining commands with ;,
run each setup command (new-session, set-option, split-window, etc.)
as individual exec.Command calls. Only tmux attach uses ExecProcess
so it properly takes over the terminal.
2026-08-09 21:34:46 +02:00
stefankoelle 9e6acdedc2 Fix Windows: use cmd.exe /c for tmux command chain
psmux on Windows doesn't handle ; separators when args are passed
individually via exec.Command. On Windows, build the full tmux
command string and run it through cmd.exe /c instead.
2026-08-09 21:27:37 +02:00
stefankoelle 07662dc9b7 Add debug logging to startTmuxSession for Windows troubleshooting 2026-08-09 21:20:27 +02:00
stefankoelle 38b28f64fb Fix: no-args should start TUI, not print help
The condition showHelp||len(filtered)==0 caused the tool to always
print usage and exit. Now only --help triggers usage+exit.
2026-08-09 21:00:45 +02:00
stefankoelle 6a749811c7 LICENSE 2026-08-09 20:59:04 +02:00
stefankoelle 313dffcbbf Fix Windows: remove -- separator (psmux incompatible), add unknown command error
- Remove -- separator from tmux args (psmux on Windows doesn't support it)
- Unknown subcommands now show error + usage (exit 1) instead of starting TUI
- --help now always shows usage and exits (no fallthrough to TUI)
2026-08-09 20:56:39 +02:00
stefankoelle 8546ac9bf2 Fix: kill stale tmux session as separate command, not in chain
tmux aborts the entire command chain when kill-session fails (no
existing session). This caused 'no current target' error on Linux.

The kill-session is now a separate exec.Command() call before
starting the tmux chain, ignoring any error.
2026-08-09 20:45:44 +02:00
stefankoelle 4c0650c43b Fix tmux: add -- separator for shell commands, kill stale sessions 2026-08-09 20:33:07 +02:00
stefankoelle e36ac3568f Add --help, use os.Executable() for panel path, improve usage text 2026-08-09 20:29:55 +02:00
stefankoelle 697017e496 Add kctl-tui doctor command for health checks 2026-08-09 20:26:55 +02:00
stefankoelle 36a7cc5e3a Windows support: psmux hint, install.ps1, updated docs 2026-08-09 20:14:54 +02:00
stefankoelle e48bd790bd fix: move tool checks before tea.ExecProcess to fix namespace selection 2026-08-09 19:38:21 +02:00
stefankoelle 6795ee1f4f fix: tmux exec type mismatch, add --version flag, add config check command 2026-08-09 19:30:14 +02:00
stefankoelle edc02d48d4 Phase 2 hardening: binary secret handling, tool checks, input validation, verbose flag, diff scroll 2026-08-09 19:23:37 +02:00
stefankoelle 2c8e8016ff fix: config error handling, stderr separation, single-env pane, namespace sort 2026-08-09 19:12:19 +02:00
stefankoelle 886efa5958 Add go.sum and tidy indirect dependencies 2026-08-09 19:07:31 +02:00
stefankoelle c4d54d153f update docs 2026-08-09 19:00:55 +02:00
32 changed files with 1324 additions and 168 deletions
+38
View File
@@ -0,0 +1,38 @@
---
name: Bug Report
about: Report a bug to help us improve kctl-tui
title: ""
labels: bug
assignees: ""
---
## Describe the Bug
A clear description of what the bug is.
## Steps to Reproduce
1. Run `kctl-tui ...`
2. Select '...'
3. See error
## Expected Behavior
What you expected to happen.
## Actual Behavior
What actually happened.
## Environment
- OS: [e.g. Windows 11, Ubuntu 24.04, macOS 15]
- Go version: [e.g. 1.22.5]
- kctl-tui version: [e.g. v0.2.0]
- kubectl version: [e.g. v1.30.0]
- tmux/psmux version: [e.g. tmux 3.4, psmux latest]
- Terminal: [e.g. Windows Terminal, iTerm2, GNOME Terminal]
## Additional Context
Config file (without secrets), error logs, or screenshots.
+23
View File
@@ -0,0 +1,23 @@
---
name: Feature Request
about: Suggest a new feature or improvement
title: ""
labels: enhancement
assignees: ""
---
## Problem
What problem does this feature solve?
## Proposed Solution
Describe the solution you'd like.
## Alternatives Considered
Any alternative solutions or workarounds you considered.
## Additional Context
Any mockups, examples, or references.
+23
View File
@@ -0,0 +1,23 @@
## Summary
Brief description of what this PR does.
## Changes
- ...
## Related Issues
Closes #
## Testing
- [ ] `go vet ./...` passes
- [ ] `go test ./...` passes
- [ ] Manually tested on [OS]
## Checklist
- [ ] Code follows existing style
- [ ] No new comments added (unless necessary)
- [ ] CHANGELOG.md updated (if applicable)
+25 -11
View File
@@ -15,11 +15,11 @@ jobs:
name: Test name: Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- uses: actions/setup-go@v5 - uses: actions/setup-go@v7
with: with:
go-version: "1.22" go-version: "1.24"
cache: false cache: false
- name: Tidy dependencies (generates/updates go.sum) - name: Tidy dependencies (generates/updates go.sum)
@@ -40,11 +40,11 @@ jobs:
goos: [linux, windows, darwin] goos: [linux, windows, darwin]
goarch: [amd64, arm64] goarch: [amd64, arm64]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- uses: actions/setup-go@v5 - uses: actions/setup-go@v7
with: with:
go-version: "1.22" go-version: "1.24"
cache: false cache: false
- name: Tidy dependencies (generates/updates go.sum) - name: Tidy dependencies (generates/updates go.sum)
@@ -60,11 +60,11 @@ jobs:
ext="" ext=""
if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi if [ "${{ matrix.goos }}" = "windows" ]; then ext=".exe"; fi
out="dist/kctl-tui-${{ matrix.goos }}-${{ matrix.goarch }}${ext}" 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" echo "Built $out"
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: kctl-tui-${{ matrix.goos }}-${{ matrix.goarch }} name: kctl-tui-${{ matrix.goos }}-${{ matrix.goarch }}
path: dist/* path: dist/*
@@ -75,13 +75,27 @@ jobs:
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/download-artifact@v4 - uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Extract release notes from commit message
id: release
run: |
MSG=$(git log -1 --pretty=format:"%B" "${{ github.sha }}")
{
echo 'body<<EOF'
echo "$MSG"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@v8
with: with:
path: dist path: dist
merge-multiple: true merge-multiple: true
- name: Create release and upload binaries - name: Create release and upload binaries
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
body: ${{ steps.release.outputs.body }}
files: dist/* files: dist/*
generate_release_notes: true
+2 -2
View File
@@ -1,8 +1,8 @@
# Binaries # Binaries
/bin/ /bin/
/dist/ /dist/
kctl-tui /kctl-tui
kctl-tui.exe /kctl-tui.exe
# Go # Go
*.test *.test
+35
View File
@@ -0,0 +1,35 @@
---
description: Create and push a release tag (e.g. /create-release 1.0.0)
---
Create a release tag and push it to origin. The GitHub Action will automatically build for all platforms and create the GitHub Release.
## Steps
1. Validate the version argument ($ARGUMENTS):
- Must be provided, otherwise show error and stop
- Must match semver format (e.g. 1.0.0, 0.9.1, 2.0.0-beta.1)
2. Check for uncommitted changes:
- Run `git status --porcelain`
- If any output, warn the user and stop (commit first)
3. Analyze changes since last release:
- Run `git log --oneline $(git describe --tags --abbrev=0 HEAD)..HEAD` to list all commits
- Read the changed files to understand context
- Write a concise, well-structured release summary in English with:
- A one-line overview
- Bullet points for each notable change (features, fixes, breaking changes)
- Keep it developer-friendly, no fluff
4. Create release commit with the summary as message:
- Run `git commit --allow-empty -m "release v$ARGUMENTS\n\n<summary>"`
- The commit message IS the release notes — the GitHub Action picks it up automatically
5. Create annotated tag on that commit:
- Run `git tag -a v$ARGUMENTS -m "Release v$ARGUMENTS"`
6. Push commit and tag:
- Run `git push origin main --tags` (or current branch)
7. Confirm success with the version number
+59
View File
@@ -0,0 +1,59 @@
# Changelog
All notable changes to kctl-tui will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [0.2.0] - 2026-08-09
### Added
- **Windows support** via [psmux](https://github.com/marlocarlo/psmux) as tmux-compatible multiplexer
- `install.ps1` PowerShell install script for Windows
- `--help` flag with full usage documentation
- `--version` / `-v` flag (set via `-ldflags` at build time)
- `kctl-tui doctor` command to verify tools, config and cluster connectivity
- `kctl-tui config check` command to validate `~/.kctl-tui/config.yaml`
- `--verbose` flag for debug logging of all kubectl/aws commands to stderr
- Binary secret value detection (`IsBinary`) — base64 or non-UTF-8 content is flagged
- ExternalSecret name validation before force-sync
- `CheckTool()` and `CheckAWSAuth()` helpers for pre-flight checks
- Diff table scroll support (j/k, up/down arrows)
- `--command pods` flag for k9s to start directly in pod view
- `--namespace` flag for k9s (Windows compatibility)
### Fixed
- Tmux session cleanup: stale sessions are killed before creating new ones
- Panel path: uses `os.Executable()` instead of PATH lookup for correct binary
- k9s on Windows: `--namespace` instead of `-n`, direct pods view
- `--help` always shows usage and exits (no fallthrough to TUI)
- Unknown subcommands show error message + usage (exit 1)
- Panel quit: properly closes tmux session and exits
- Bubbles list filter disabled to prevent stuck filter state after tmux return
### Changed
- Config uses `k8s_secret_name_template` for Kubernetes secret names (separate from AWS `secret_name_template`)
- Contexts resolved via template (`context_template`) instead of live kubectl discovery
- Panel redesigned: env-first menu (quit/beta/prod) with secrets sync + redeploy
- Full-mode navigation starts at team selection with default context
- `tea.ExecProcess` only used for `tmux attach` — setup commands run synchronously
### Removed
- Context-pair logic (superseded by env-template based context resolution)
- Live kubectl discovery (superseded by config templates)
## [0.1.0] - 2026-07-XX
### Added
- Initial release with Bubble Tea TUI
- 3-pane tmux orchestration (control panel + 2x k9s)
- Team/namespace navigation with kubectl context switching
- Panel mode with redeploy and secrets diff/force-sync
- AWS SSO integration with interactive login prompt
- CI/CD pipeline with Go build and release
- Linux/macOS install script
[Unreleased]: https://github.com/skoelle/kctl-tui/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/skoelle/kctl-tui/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/skoelle/kctl-tui/releases/tag/v0.1.0
+95
View File
@@ -0,0 +1,95 @@
# Contributing to kctl-tui
Thanks for your interest in contributing! This document explains how to get
started.
## Development Setup
```bash
git clone https://github.com/skoelle/kctl-tui.git
cd kctl-tui
go mod download
```
### Prerequisites
- Go 1.22+
- kubectl, k9s, tmux (or psmux on Windows)
- An active Kubernetes cluster for integration testing
### Running Locally
```bash
go run ./cmd/kctl-tui
```
### Building
```bash
go build -o kctl-tui ./cmd/kctl-tui
```
### With Version Tag
```bash
go build -ldflags "-X main.version=v0.2.0" -o kctl-tui ./cmd/kctl-tui
```
## Project Structure
```
cmd/kctl-tui/ Entry points (main, full mode, panel mode)
internal/config/ YAML config loading and template resolution
internal/kctl/ Pure logic (secret diffing, template engine)
internal/kubeexec/ kubectl/aws/tmux wrappers (side effects only)
```
### Architecture Rules
- **Pure logic** goes into `internal/kctl` or `internal/config` — no exec, no I/O.
- **Side effects** (running kubectl, aws, tmux) go into `internal/kubeexec`.
- **UI** lives in `cmd/kctl-tui/` — Bubble Tea models, views, handlers.
- Unit tests cover pure logic only. Side-effect packages are tested via
integration/manual tests.
## Testing
```bash
go vet ./... # static analysis
go test ./... # unit tests
```
There are no integration tests yet. Manual testing against a real cluster is
expected for UI and kubeexec changes.
## Code Style
- Standard Go formatting (`gofmt`).
- No comments unless the logic is non-obvious.
- Error messages should be actionable — tell the user what to fix.
- Log commands with `kubeexec.VerboseLog()` when `--verbose` is active.
## Commits
- One logical change per commit.
- Imperative mood in commit messages ("Add ...", "Fix ...", "Remove ...").
- No co-authors in commits.
## Pull Requests
1. Fork the repo and create a feature branch.
2. Make your changes following the style guide above.
3. Run `go vet` and `go test`.
4. Open a PR against `main` with a clear description of what changed and why.
5. Reference any related issues.
## Issues
- Use the provided issue templates.
- Include your OS, Go version, and kctl-tui version.
- For bugs: steps to reproduce, expected vs actual behavior.
## License
By contributing, you agree that your contributions will be licensed under the
MIT License.
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 Stefan Koelle Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+103 -44
View File
@@ -14,18 +14,20 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
API is unreachable. API is unreachable.
- [x] `README.md`, `config.example.yaml`. - [x] `README.md`, `config.example.yaml`.
## Phase 1 — Core logic + navigation (done, initial version) ## Phase 1 — Core logic + navigation (done)
- [x] `internal/kctl`: pure, unit-tested logic — - [x] `internal/kctl`: pure, unit-tested logic —
context-pair matching (`FindNextContext`), namespace/label filtering template resolution (`ResolveTemplate`), namespace/label filtering
(`DistinctLabelValues`, `NamespacesForLabelValue`), and secret diffing (`DistinctLabelValues`, `NamespacesForLabelValue`), and secret diffing
(`DiffSecretValues`, `AnyMismatch`). (`DiffSecretValues`, `AnyMismatch`).
- [x] `internal/config`: YAML config loading (`context_pairs`, - [x] `internal/config`: YAML config loading with template-based context
`team_label_key`), with safe defaults when no config file exists yet. and secret name resolution (`ContextTemplate`, `SecretNameTemplate`,
`K8sSecretNameTemplate`), with safe defaults when no config file
exists yet.
- [x] `internal/kubeexec`: thin wrappers around `kubectl`/`aws` CLI calls - [x] `internal/kubeexec`: thin wrappers around `kubectl`/`aws` CLI calls
(contexts, namespaces, deployments, rollout restart/status, listing (namespaces, deployments, rollout restart/status, fetching AWS
AWS secrets, reading all fields of a Kubernetes secret, ExternalSecret secrets by template-resolved ID, reading all fields of a Kubernetes
annotation). secret, ExternalSecret annotation, AWS auth check).
- [x] `cmd/kctl-tui` "full" mode: Bubble Tea navigation for - [x] `cmd/kctl-tui` "full" mode: Bubble Tea navigation for
context -> team -> namespace, with `Esc` correctly popping back one context -> team -> namespace, with `Esc` correctly popping back one
level at a time, defaults pre-selected from the currently active level at a time, defaults pre-selected from the currently active
@@ -38,53 +40,110 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
- [x] `cmd/kctl-tui` "panel" mode: - [x] `cmd/kctl-tui` "panel" mode:
- Redeploy: pick a deployment from a list, confirm, then - Redeploy: pick a deployment from a list, confirm, then
`rollout restart` + `rollout status`. `rollout restart` + `rollout status`.
- Secrets: pick an AWS region, then pick the actual secret from a - Secrets: AWS auth check with interactive SSO login fallback,
**list of all AWS Secrets Manager secrets** in that region (no more then automatically resolve the AWS secret ID (from
manual secret-ID typing), enter the matching Kubernetes secret `secret_name_template`) and Kubernetes secret name (from
name, and automatically diff **every field** of both secrets in one `k8s_secret_name_template`), fetch both, diff **every field**
table (key / AWS value / Kubernetes value / match status). If any in one table (key / AWS value / Kubernetes value / match status).
field differs, offer a single force-sync request for the **whole If any field differs, offer a single force-sync request for the
secret** (one ExternalSecret annotation), not per individual field. **whole secret** (one ExternalSecret annotation).
- `Esc` closes the whole tmux session (`tmux kill-session`). - `Esc` closes the whole tmux session (`tmux kill-session`).
## Phase 2 — Hardening (open) ## Phase 2 — Hardening (done)
- [ ] Handle non-JSON AWS secrets and Kubernetes secrets with binary - [x] Handle non-JSON AWS secrets and Kubernetes secrets with binary
(non-UTF8) values more gracefully in the diff table (currently (non-UTF8) values more gracefully in the diff table.
falls back to a single "value" key or may render oddly).
- [ ] Add integration-style tests against a local `kind`/`k3d` cluster in - [ ] Add integration-style tests against a local `kind`/`k3d` cluster in
CI for the `kubeexec` wrappers currently excluded from automated CI for the `kubeexec` wrappers currently excluded from automated
testing. testing. *(Deferred — superseded by client-go in v0.3.0)*
- [ ] Input validation for the free-text steps in "panel" mode (empty - [x] Input validation for the free-text steps in "panel" mode.
region/secret name, invalid characters). - [x] Graceful handling when `tmux`, `k9s`, or `aws` are not installed.
- [ ] Graceful handling when `tmux`, `k9s`, or `aws` are not installed - [x] Structured logging / `--verbose` flag for troubleshooting failed
(currently surfaces the raw exec error).
- [ ] Structured logging / `--verbose` flag for troubleshooting failed
`kubectl`/`aws` calls. `kubectl`/`aws` calls.
- [ ] Paginate/scroll the secrets diff table for secrets with many fields - [x] Paginate/scroll the secrets diff table for secrets with many fields.
instead of relying on terminal wrapping.
## Phase 3 — Windows-native support (open, secondary priority) ## Phase 3 — Windows-native support (done)
- [ ] Detect OS at runtime; on native Windows (no WSL), fall back to - [x] Windows support via [psmux](https://github.com/marlocarlo/psmux).
`wt.exe split-pane` instead of `tmux` for the status panes. - [x] `install.ps1` — PowerShell install script for Windows.
- [ ] Document/implement that `Tab`-based context switching and - [x] Updated README and SPEC with Windows + psmux setup instructions.
`Esc`-triggered session close are **not** available in the native
Windows fallback, per SPEC.md 3.6 — the panes must be closed
manually there.
## Phase 4 — Nice-to-haves (open, not committed) ## Phase 4 — Nice-to-haves (done for v0.2.0)
- [ ] Optional direct use of `client-go` instead of shelling out to - [x] `--version` flag — prints version, set via `-ldflags` at build time.
`kubectl`, for faster context/namespace/label queries. - [x] Config validation command (`kctl-tui config check`) — validates
- [ ] Config validation command (`kctl-tui config check`) that reports required fields and shows a resolved context example.
unknown label keys or context names not present in the current - [x] `kctl-tui doctor` — health check for tools, config, and connectivity.
kubeconfig. - [x] `--help` flag with full usage documentation.
- [ ] Homebrew tap / `scoop` manifest as additional install options - [x] CHANGELOG.md, CONTRIBUTING.md, GitHub Issue/PR templates.
alongside `install.sh`.
- [ ] Optional heuristic to suggest a matching Kubernetes secret name for ---
a chosen AWS secret (e.g. by common naming convention), instead of
always asking for it manually. ## Bugfix Sprint — between v0.2.0 and v0.3.0
- [ ] **Dead code** `cmd/kctl-tui/main.go:47-49` — empty
`if len(filtered) == 0` block with comment. Remove.
- [ ] **Redundant logic** `internal/kctl/diff.go:60-63`
`if lb || rb { match = l == r }` is identical to the line above.
Either dead or misunderstood.
- [ ] **Diff-scroll is a no-op** `cmd/kctl-tui/panel.go:398-413`
`renderDiffTable` is always called with `visibleHeight=0`, so
`end = len(entries)` is always true. j/k/arrows only change the
offset text but the table is always fully rendered. The CHANGELOG
promises "Diff table scroll support" but the feature is incomplete.
- [ ] **README duplicate** `README.md:98-102` — "This downloads the latest
release binary..." appears twice (once "to your PATH", once
"to /usr/local/bin"). Edit leftover.
- [ ] **go mod tidy in CI** `build.yml` — mutates `go.sum` during the
build instead of enforcing a tidy check. If someone forgets to tidy,
it's silently fixed instead of blocking the PR.
- [ ] **Bubbles filter disabled** `full.go:53`, `panel.go:87` — workaround
for the stuck-filter bug (commit 7db58b6). Users can no longer
type-to-filter. Worth restoring with a proper fix later.
- [ ] **Kleinkram:**
- `fmt.Errorf("%s", msg)``errors.New(msg)` in `kubeexec.go:41`
- `helpers.go` is a pointless 1:1 passthrough to the `kctl` package
- `IsBinary` also marks UTF-8 special chars (>0x7F) as "binary"
- [ ] **SECURITY.md** — fehlt, besonders wichtig für ein Tool mit
Secret-Workflows.
- [ ] **dependabot.yml** — automatische Dependency-Updates.
- [ ] **Checksummen für Release-Assets** — CI erzeugt Binaries aber keine
`.sha256`-Dateien; für `curl | bash`-Install wichtig.
- [ ] **Makefile / justfile** — Build/Test/Vet-Komfort.
- [ ] **golangci-lint**`go vet` allein ist dünn; optional aber empfohlen.
- [ ] **PLAN.md aufräumen** — erledigte Phasen als „Done" markieren,
offene Items konsolidieren.
---
## Roadmap
### v0.3.0 — client-go integration
Replace kubectl shell-outs with direct API calls via `client-go`.
- [ ] Add `internal/kubeclient` package using `client-go` for:
- Context/namespace/label queries (faster than kubectl JSON parsing)
- Deployment list and rollout restart/status
- Secret fetch (AWS Secrets Manager via SDK, K8s secrets via API)
- ExternalSecret annotation update
- [ ] Keep `internal/kubeexec` as fallback for operations not yet
covered by `client-go`
- [ ] Remove `kind`/`k3d` integration test plan (client-go has its own
test coverage)
- [ ] Add unit tests with `fake.Clientset` for the new package
### v1.0 — Stable release
Production-ready with package manager support and documentation.
- [ ] Homebrew tap (`skoelle/homebrew-tap`) with `kctl-tui` formula
- [ ] Scoop manifest (`skoelle/scoop-bucket`) for Windows
- [ ] Full test coverage for `internal/kubeclient`
- [ ] Documentation: architecture diagram, config reference, troubleshooting
- [ ] Semantic versioning policy documented
- [ ] Deprecation policy for config schema changes
## Notes for contributors ## Notes for contributors
+48 -13
View File
@@ -42,9 +42,9 @@ See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
| -> 1) Quit 2) beta 3) prod | | -> 1) Quit 2) beta 3) prod |
| each with: a) Secrets sync b) Redeploy | | each with: a) Secrets sync b) Redeploy |
+--------------------------------------------------+ +--------------------------------------------------+
| k9s --context <resolved beta context> -n <ns> | | k9s --context <resolved beta context> --namespace <ns> --command pods |
+--------------------------------------------------+ +--------------------------------------------------+
| k9s --context <resolved prod context> -n <ns> | | k9s --context <resolved prod context> --namespace <ns> --command pods |
+--------------------------------------------------+ +--------------------------------------------------+
``` ```
@@ -68,12 +68,16 @@ See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
Configuration below - they must already exist in your kubeconfig, e.g. Configuration below - they must already exist in your kubeconfig, e.g.
added via `aws eks update-kubeconfig`). added via `aws eks update-kubeconfig`).
- `k9s` (used for the two status panes). - `k9s` (used for the two status panes).
- `tmux` (used for the 3-pane layout). On Windows, this means running - `tmux` (used for the 3-pane layout). On **Linux/macOS**, install
kctl-tui inside **WSL**`tmux` has no native Windows port. Native `tmux` via your package manager. On **Windows**, install
Windows Terminal has its own split-pane feature, but it cannot be [psmux](https://github.com/marlocarlo/psmux) — a native,
scripted from inside a pane the way `tmux` can, so the automated 3-pane tmux-compatible terminal multiplexer:
layout and the `Esc` session handling described above are only fully ```powershell
supported under Linux/WSL. See SPEC.md section 3.6 for details. scoop install psmux
# or
cargo install psmux
```
psmux provides a `tmux` command, so kctl-tui works without changes.
- `aws` CLI, configured with credentials, only needed for the secrets - `aws` CLI, configured with credentials, only needed for the secrets
workflow. workflow.
@@ -85,6 +89,12 @@ See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
curl -fsSL https://raw.githubusercontent.com/skoelle/kctl-tui/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/skoelle/kctl-tui/main/install.sh | bash
``` ```
### Quick install (Windows)
```powershell
irm https://raw.githubusercontent.com/skoelle/kctl-tui/main/install.ps1 | iex
```
This downloads the latest release binary for your OS/architecture from This downloads the latest release binary for your OS/architecture from
GitHub Releases and installs it to `/usr/local/bin/kctl-tui`. GitHub Releases and installs it to `/usr/local/bin/kctl-tui`.
@@ -125,6 +135,7 @@ aws_region: "eu-central-1"
aws_account_id: "123456789012" aws_account_id: "123456789012"
secret_name_template: "tf-{namespace}-{env}-secrets" secret_name_template: "tf-{namespace}-{env}-secrets"
k8s_secret_name_template: "{namespace}-common-secrets"
context_template: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1" context_template: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
team_label_key: "example.org/team" team_label_key: "example.org/team"
@@ -144,6 +155,10 @@ aws_sso_login_command: "aws sso login"
- `secret_name_template`: builds the AWS Secrets Manager secret ID from - `secret_name_template`: builds the AWS Secrets Manager secret ID from
the chosen namespace and environment. Placeholders: `{namespace}`, the chosen namespace and environment. Placeholders: `{namespace}`,
`{env}`. `{env}`.
- `k8s_secret_name_template`: builds the Kubernetes secret name from the
chosen namespace. Kept separate from `secret_name_template` because the
two sides commonly follow different naming conventions. Placeholders:
`{namespace}`.
- `context_template`: builds the actual kubectl context name/ARN from - `context_template`: builds the actual kubectl context name/ARN from
region, account ID, environment, and context. Placeholders: `{region}`, region, account ID, environment, and context. Placeholders: `{region}`,
`{account_id}`, `{env}`, `{context}`. Adjust the literal parts (`tf-`, `{account_id}`, `{env}`, `{context}`. Adjust the literal parts (`tf-`,
@@ -162,17 +177,37 @@ aws_sso_login_command: "aws sso login"
that way — it typically contains your organization's internal account ID, that way — it typically contains your organization's internal account ID,
context naming, and label names. context naming, and label names.
## WSL setup notes ## Windows notes
If `kubectx`/`kubens` or `kctl-tui` report a missing kubeconfig inside WSL, On native Windows (without WSL), install [psmux](https://github.com/marlocarlo/psmux)
your kubeconfig most likely only exists on the Windows side. Symlink it for the 3-pane layout. psmux is a native Windows terminal multiplexer
into WSL: that is tmux-compatible — kctl-tui works without code changes:
```powershell
scoop install psmux
# or
cargo install psmux
```
If you prefer WSL, symlink your kubeconfig into WSL:
```bash ```bash
mkdir -p ~/.kube mkdir -p ~/.kube
ln -s /mnt/c/Users/<your-windows-username>/.kube/config ~/.kube/config ln -s /mnt/c/Users/<your-windows-username>/.kube/config ~/.kube/config
``` ```
## Usage
```bash
kctl-tui # start the TUI (full navigation mode)
kctl-tui --help # show all commands and flags
kctl-tui --version # print version
kctl-tui --verbose # enable debug logging to stderr
kctl-tui doctor # check if all tools, config and connections are OK
kctl-tui config check # validate ~/.kctl-tui/config.yaml
kctl-tui panel --context=... --ns=... --team=... # internal (called by tmux)
```
## Development ## Development
```bash ```bash
@@ -189,4 +224,4 @@ and untested, since it has no meaningful behavior without a live cluster.
## License ## License
[MIT](LICENSE) Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
+67 -58
View File
@@ -5,11 +5,12 @@
A single terminal tool as the central entry point for everyday Kubernetes A single terminal tool as the central entry point for everyday Kubernetes
work, bundling the most common workflows currently done via long work, bundling the most common workflows currently done via long
`kubectl`/`k9s`/`aws-cli` commands, operable through a text UI (arrow keys, `kubectl`/`k9s`/`aws-cli` commands, operable through a text UI (arrow keys,
Esc, Tab) instead of long typed commands. Esc) instead of long typed commands.
Target platform: **Linux / WSL** (primary usage scenario, since split Target platform: **Linux / WSL / Windows** (primary usage scenario, since
panes require a real terminal multiplexer). Native Windows (without WSL) split panes require a real terminal multiplexer). On Windows, use
is possible but with reduced split-view functionality (see 3.6). [psmux](https://github.com/marlocarlo/psmux) as tmux-compatible
multiplexer.
**Technology decision: Go + Bubble Tea** (see section 5). **Technology decision: Go + Bubble Tea** (see section 5).
@@ -49,9 +50,6 @@ Navigation:
(`tmux kill-session`, closing both k9s panes as well) and then moves the (`tmux kill-session`, closing both k9s panes as well) and then moves the
Go tool's screen stack one level up: namespace selection -> team Go tool's screen stack one level up: namespace selection -> team
selection -> context selection. selection -> context selection.
- **Tab** in the control pane switches the context pair according to the
context-pair pattern (see 3.6) for both status panes simultaneously; the
namespace stays the same.
### 3.2 Namespace grouping via labels ### 3.2 Namespace grouping via labels
@@ -64,10 +62,10 @@ Navigation:
kubectl get ns -o jsonpath='{range .items[*]}{.metadata.labels["<team-label-key>"]}{"\n"}{end}' | sort -u kubectl get ns -o jsonpath='{range .items[*]}{.metadata.labels["<team-label-key>"]}{"\n"}{end}' | sort -u
``` ```
- The actual label key is project-specific and set via the configuration - The actual label key is project-specific and set via the configuration
file (see 3.6), not hardcoded. file (see `team_label_key` in config), not hardcoded.
- Namespace labeling is a prerequisite (one-time setup outside the tool). - Namespace labeling is a prerequisite (one-time setup outside the tool).
### 3.3 Layout: 3-panel view (core design change vs. earlier drafts) ### 3.3 Layout: 3-panel view
Once start navigation is complete, the tool opens a tmux session with Once start navigation is complete, the tool opens a tmux session with
**three panes**, started with a single command: **three panes**, started with a single command:
@@ -78,9 +76,9 @@ Once start navigation is complete, the tool opens a tmux session with
| -> runs the kctl-tui binary in "panel" mode | | -> runs the kctl-tui binary in "panel" mode |
| -> menu: Redeploy, secrets diff | | -> menu: Redeploy, secrets diff |
+--------------------------------------------------+ +--------------------------------------------------+
| Pane 1 (middle): k9s --context <context-a> -n <ns>| | Pane 1 (middle): k9s --context <context-a> --namespace <ns> --command pods|
+--------------------------------------------------+ +--------------------------------------------------+
| Pane 2 (bottom): k9s --context <context-b> -n <ns>| | Pane 2 (bottom): k9s --context <context-b> --namespace <ns> --command pods|
+--------------------------------------------------+ +--------------------------------------------------+
``` ```
@@ -92,11 +90,15 @@ not cover: **redeploy** and **secrets diff**.
Example startup command (generic placeholders): Example startup command (generic placeholders):
``` ```
# Kill stale session first (separate command — tmux aborts on kill-session error).
tmux kill-session -t kctl
tmux new-session -d -s kctl \ tmux new-session -d -s kctl \
"kctl-tui panel --ctx=$CTX_A --ns=$NS --team=$TEAM" \; \ "kctl-tui panel --context=$CTX_A --ns=$NS --team=$TEAM" \; \
split-window -v "k9s --context $CTX_A -n $NS" \; \ set-option -t kctl remain-on-exit on \; \
split-window -v "k9s --context $CTX_B -n $NS" \; \ split-window -v -t kctl:0.0 "k9s --context $CTX_A --namespace $NS --command pods" \; \
select-layout main-horizontal \; \ split-window -v -t kctl:0.1 "k9s --context $CTX_B --namespace $NS --command pods" \; \
select-layout -t kctl even-vertical \; \
select-pane -t kctl:0.0 \; \
attach -t kctl attach -t kctl
``` ```
@@ -115,63 +117,72 @@ Switching between panes: `Ctrl-b` + arrow key, or `Ctrl-b` `o`.
### 3.5 AWS Secrets Manager <-> Kubernetes Secret diff — in the control pane ### 3.5 AWS Secrets Manager <-> Kubernetes Secret diff — in the control pane
1. Load the secret from AWS Secrets Manager: 1. Before entering the secrets workflow, verify the AWS session is valid
(`aws sts get-caller-identity`). If expired, offer to run the
configured SSO login command interactively.
2. Resolve the AWS Secrets Manager secret ID from `secret_name_template`
(using namespace + env) and the Kubernetes secret name from
`k8s_secret_name_template` (using namespace). No manual input required
for either name.
3. Fetch the AWS secret:
`aws secretsmanager get-secret-value --secret-id <secret-id> --region <region> --query SecretString --output text`. `aws secretsmanager get-secret-value --secret-id <secret-id> --region <region> --query SecretString --output text`.
2. Show the contained keys for selection. 4. Fetch all fields of the Kubernetes secret and base64-decode them:
3. Load the matching Kubernetes secret field: `kubectl -n <ns> get secret <secret-name> -o json`.
`kubectl -n <ns> get secret <secret-name> -o jsonpath='{.data.<field>}'`, 5. Compare every field at once in a table (key / AWS value / Kubernetes
base64-decode it. value / match status).
4. Compare the values (identical / different). 6. On mismatch, optionally request a force-sync for the whole secret:
5. On mismatch, optionally request a force-sync:
`kubectl -n <ns> annotate externalsecret <name> force-sync=<unix-timestamp> --overwrite`. `kubectl -n <ns> annotate externalsecret <name> force-sync=<unix-timestamp> --overwrite`.
All names (secret ID, secret name, field name, ExternalSecret name) are The ExternalSecret object name for the force-sync annotation is the only
asked for interactively at runtime, never hardcoded in the tool. value asked for interactively at runtime.
### 3.6 Context-pair pattern (configurable) — drives both status panes at once ### 3.6 Context resolution via templates
Requirement: the Tab switch in the control pane must switch **both** The actual kubectl context name/ARN for each environment is computed from
status panes below it, not just an internal state. a configurable template at startup. The two k9s status panes and all
kubectl calls in the control pane use the resolved context.
Configuration format (e.g. `~/.kctl-tui/config.yaml`), purely illustrative Configuration format (e.g. `~/.kctl-tui/config.yaml`), purely illustrative
with generic placeholders: with generic placeholders:
```yaml ```yaml
context_pairs: contexts:
- name: "environment-pair-1" - "internal"
contexts: ["<context-a1>", "<context-a2>"] - "external"
- name: "environment-pair-2" default_context: "internal"
contexts: ["<context-b1>", "<context-b2>"]
envs:
- "beta"
- "prod"
aws_region: "eu-central-1"
aws_account_id: "123456789012"
context_template: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
secret_name_template: "tf-{namespace}-{env}-secrets"
k8s_secret_name_template: "{namespace}-common-secrets"
team_label_key: "<organization>/<label-name>" team_label_key: "<organization>/<label-name>"
``` ```
Behavior on Tab in the control pane: The `context_template` replaces `{region}`, `{account_id}`, `{env}`, and
`{context}` placeholders with the configured values and the currently
selected environment/context. The resolved value must match an existing
context in your kubeconfig (e.g. added via `aws eks update-kubeconfig`).
1. Determine the current context pair from the configuration. **Windows support:** On native Windows, install
2. Restart both k9s panes via [psmux](https://github.com/marlocarlo/psmux) — a native, tmux-compatible
`tmux respawn-pane -k -t kctl:0.1 "k9s --context <newA> -n <ns>"` and terminal multiplexer. psmux provides a `tmux` command, so kctl-tui works
`... kctl:0.2 ...` (namespace stays the same). without code changes (including `Esc`-triggered session termination).
3. If the current context is in no configured list: show a hint in the Alternatively, run kctl-tui inside WSL with standard `tmux`.
control pane instead of an error.
4. No action outside this configuration — no error, only a hint.
**Platform limitation on Windows without WSL:** `respawn-pane`/
`kill-session` are tmux-specific. Windows Terminal (`wt.exe`) offers no
equivalent scripting to replace panes or end the session from inside a
pane. On plain Windows (without WSL), only a simplified flow is possible:
k9s panes are closed manually (`q`, then `Ctrl+Shift+W`); Tab switching and
automatic session termination are unavailable there. This limitation is
the main reason the primary target system is set to Linux/WSL.
## 4. Non-functional requirements ## 4. Non-functional requirements
- **Primary platform Linux/WSL**, secondary native Windows with reduced - **Platforms**: Linux, macOS, Windows (via psmux or WSL).
functionality.
- **Single-binary distribution** without external runtime dependency (Go - **Single-binary distribution** without external runtime dependency (Go
provides this natively). provides this natively).
- **External dependencies**: `kubectl` mandatory; `tmux`, `k9s`, `aws-cli` - **External dependencies**: `kubectl` mandatory; `tmux`/`psmux`, `k9s`,
depending on the action used. `aws-cli` depending on the action used.
- **Low startup time**, noticeably faster than the current `kubens` - **Low startup time**, noticeably faster than the current `kubens`
experience. experience.
- **No destructive actions without confirmation** (redeploy, force-sync). - **No destructive actions without confirmation** (redeploy, force-sync).
@@ -233,16 +244,14 @@ Rejected options (see discussion history):
- These fixes are a prerequisite before the tool can be meaningfully - These fixes are a prerequisite before the tool can be meaningfully
tested, since it builds directly on `kubectl config`. tested, since it builds directly on `kubectl config`.
## 8. Open items / out of scope (v1) ## 8. Open items / out of scope
- No automatic label setup for namespaces (migration is a separate, - No automatic label setup for namespaces (migration is a separate,
one-time task). one-time task).
- Split view v1 fixed at 2 status panes + 1 control pane (3 panes total). - Split view fixed at 2 status panes + 1 control pane (3 panes total).
- No RBAC/permission checks before executing sensitive actions — the tool - No RBAC/permission checks before executing sensitive actions — the tool
assumes existing kubectl permissions. assumes existing kubectl permissions.
- Configuration file format (`config.yaml`) is a proposal, not finally - Configuration file format (`config.yaml`) is defined and implemented;
agreed; concrete label keys, context names, and namespace names are concrete label keys, context names, and namespace names are
project-specific and belong exclusively in the user's local, unversioned project-specific and belong exclusively in the user's local, unversioned
configuration, not in this document or the source code. configuration, not in this document or the source code.
- Native Windows (without WSL) remains a secondary platform with manual
pane handling instead of an automated tmux lifecycle.
+55 -16
View File
@@ -1,8 +1,13 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package main package main
import ( import (
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"sort"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
@@ -45,6 +50,7 @@ func newFullModel() *fullModel {
l := list.New(nil, list.NewDefaultDelegate(), 0, 0) l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
l.Title = "kctl-tui" l.Title = "kctl-tui"
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
return &fullModel{list: l} return &fullModel{list: l}
} }
@@ -66,6 +72,9 @@ func (m *fullModel) bootstrap() tea.Msg {
if len(cfg.Envs) == 0 { if len(cfg.Envs) == 0 {
return errMsg{fmt.Errorf("no 'envs' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")} return errMsg{fmt.Errorf("no 'envs' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
} }
if err := kubeexec.CheckTool("kubectl"); err != nil {
return errMsg{err}
}
return bootstrapMsg{cfg: cfg, context: cfg.EffectiveDefaultContext()} return bootstrapMsg{cfg: cfg, context: cfg.EffectiveDefaultContext()}
} }
@@ -188,6 +197,14 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
case screenNamespace: case screenNamespace:
m.selectedNamespace = item.value m.selectedNamespace = item.value
if err := kubeexec.CheckTool("tmux"); err != nil {
m.err = err
return m, nil
}
if err := kubeexec.CheckTool("k9s"); err != nil {
m.err = err
return m, nil
}
return m, m.startTmuxSession() return m, m.startTmuxSession()
} }
return m, nil return m, nil
@@ -235,6 +252,7 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
for ns := range m.namespaces { for ns := range m.namespaces {
names = append(names, ns) names = append(names, ns)
} }
sort.Strings(names)
} else { } else {
names = namespacesForLabelValue(m.namespaces, m.cfg.TeamLabelKey, teamValue) names = namespacesForLabelValue(m.namespaces, m.cfg.TeamLabelKey, teamValue)
} }
@@ -252,31 +270,52 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
// configured envs, resolved via the context template, so both are // configured envs, resolved via the context template, so both are
// visible side by side. // visible side by side.
func (m *fullModel) startTmuxSession() tea.Cmd { func (m *fullModel) startTmuxSession() tea.Cmd {
selfPath := "kctl-tui" // resolved via PATH; see README for install instructions selfPath, err := os.Executable()
if err != nil {
selfPath = "kctl-tui" // fallback to PATH lookup
}
panelCmd := fmt.Sprintf("%s panel --context=%s --ns=%s --team=%s", panelCmd := fmt.Sprintf("%s panel --context=%s --ns=%s --team=%s",
selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam) selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam)
envA := m.cfg.Envs[0] envA := m.cfg.Envs[0]
envB := m.cfg.Envs[0]
if len(m.cfg.Envs) > 1 {
envB = m.cfg.Envs[1]
}
ctxA := m.cfg.ResolveContext(envA, m.selectedContext) ctxA := m.cfg.ResolveContext(envA, m.selectedContext)
ctxB := m.cfg.ResolveContext(envB, m.selectedContext) k9sCmdA := fmt.Sprintf("k9s --context %s --namespace %s --command pods", ctxA, m.selectedNamespace)
k9sCmdA := fmt.Sprintf("k9s --context %s -n %s", ctxA, m.selectedNamespace) kubeexec.VerboseLog("[debug] selfPath=%s\n", selfPath)
k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", ctxB, m.selectedNamespace) kubeexec.VerboseLog("[debug] panelCmd=%s\n", panelCmd)
kubeexec.VerboseLog("[debug] k9sCmdA=%s\n", k9sCmdA)
c := exec.Command("tmux", "new-session", "-d", "-s", "kctl", // Kill stale session first (ignore error if none exists).
panelCmd, ";", exec.Command("tmux", "kill-session", "-t", "kctl").Run()
"set-option", "-t", "kctl", "remain-on-exit", "on", ";",
"split-window", "-v", "-t", "kctl:0.0", k9sCmdA, ";", // Run setup commands individually — this avoids the ; separator
"split-window", "-v", "-t", "kctl:0.1", k9sCmdB, ";", // issue on Windows where psmux doesn't handle chained args.
"select-layout", "-t", "kctl", "even-vertical", ";", setup := [][]string{
"select-pane", "-t", "kctl:0.0", ";", {"new-session", "-d", "-s", "kctl", panelCmd},
"attach", "-t", "kctl", {"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 --namespace %s --command pods", ctxB, m.selectedNamespace)
kubeexec.VerboseLog("[debug] k9sCmdB=%s\n", k9sCmdB)
setup = append(setup, []string{"split-window", "-v", "-t", "kctl:0.1", k9sCmdB})
}
setup = append(setup,
[]string{"select-layout", "-t", "kctl", "even-vertical"},
[]string{"select-pane", "-t", "kctl:0.0"},
) )
for _, args := range setup {
if out, err := exec.Command("tmux", args...).CombinedOutput(); err != nil {
kubeexec.VerboseLog("[debug] tmux %s failed: %v\n%s\n", args[0], err, out)
return func() tea.Msg { return tmuxDoneMsg{err: fmt.Errorf("tmux %s: %w", args[0], err)} }
}
}
// Only attach uses tea.ExecProcess so it takes over the terminal.
c := exec.Command("tmux", "attach", "-t", "kctl")
return tea.ExecProcess(c, func(err error) tea.Msg { return tea.ExecProcess(c, func(err error) tea.Msg {
return tmuxDoneMsg{err: err} return tmuxDoneMsg{err: err}
}) })
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package main package main
import "github.com/skoelle/kctl-tui/internal/kctl" import "github.com/skoelle/kctl-tui/internal/kctl"
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package main package main
// simpleItem is a minimal implementation of list.Item used for all // simpleItem is a minimal implementation of list.Item used for all
+249 -4
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package main package main
import ( import (
@@ -5,15 +8,73 @@ import (
"os" "os"
tea "github.com/charmbracelet/bubbletea" 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() { func main() {
if len(os.Args) > 1 && os.Args[1] == "panel" { args := os.Args[1:]
if err := runPanel(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "kctl-tui panel error:", err) // Extract global flags before delegating to sub-commands.
verbose := false
showHelp := false
filtered := make([]string, 0, len(args))
for _, a := range args {
switch a {
case "--verbose":
verbose = true
case "--version", "-v":
fmt.Printf("kctl-tui %s\n", version)
fmt.Println("Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)")
fmt.Println("Licensed under the MIT License")
return
case "--help", "-h":
showHelp = true
default:
filtered = append(filtered, a)
}
}
if verbose {
kubeexec.SetVerbose(true, os.Stderr)
}
if showHelp {
printUsage()
return
}
if len(filtered) == 0 {
// No subcommand — start the full TUI.
}
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
case "doctor":
if err := runDoctor(); err != nil {
fmt.Fprintln(os.Stderr, "kctl-tui doctor error:", err)
os.Exit(1)
}
return
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", filtered[0])
printUsage()
os.Exit(1) os.Exit(1)
} }
return
} }
m := newFullModel() m := newFullModel()
@@ -23,3 +84,187 @@ func main() {
os.Exit(1) os.Exit(1)
} }
} }
func printUsage() {
fmt.Print(`kctl-tui — Kubernetes entry-point TUI
https://github.com/skoelle/kctl-tui
Usage:
kctl-tui [flags] start the TUI (full navigation mode)
kctl-tui doctor check tools, config and connections
kctl-tui config check validate ~/.kctl-tui/config.yaml
kctl-tui panel [options] control pane (called internally by tmux)
Flags:
--verbose log all kubectl/aws commands to stderr
--version print version
--help show this help
Examples:
kctl-tui # start the TUI
kctl-tui doctor # verify everything is installed
kctl-tui --verbose 2>debug.log # log commands to a file
kctl-tui config check # validate config
`)
}
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
}
func runDoctor() error {
pass := "ok"
fail := "FAIL"
warn := "WARN"
status := pass
errs := 0
check := func(label string, err error) {
if err != nil {
fmt.Printf(" [%s] %s: %v\n", fail, label, err)
status = fail
errs++
} else {
fmt.Printf(" [%s] %s\n", pass, label)
}
}
warnCheck := func(label string, err error) {
if err != nil {
fmt.Printf(" [%s] %s: %v\n", warn, label, err)
} else {
fmt.Printf(" [%s] %s\n", pass, label)
}
}
// --- Tools ---
fmt.Println("\nTools:")
check("kubectl", kubeexec.CheckTool("kubectl"))
check("tmux/psmux", kubeexec.CheckTool("tmux"))
check("k9s", kubeexec.CheckTool("k9s"))
warnCheck("aws CLI (optional)", kubeexec.CheckTool("aws"))
// --- Config ---
fmt.Println("\nConfig:")
cfgPath, err := config.DefaultPath()
if err != nil {
fmt.Printf(" [%s] config path: %v\n", fail, err)
errs++
status = fail
} else {
cfg, err := config.Load(cfgPath)
if err != nil {
fmt.Printf(" [%s] load config: %v\n", fail, err)
errs++
status = fail
} else {
check("config file exists", nil)
if len(cfg.Contexts) == 0 {
fmt.Printf(" [%s] contexts configured\n", fail)
errs++
status = fail
} else {
fmt.Printf(" [%s] contexts configured (%d)\n", pass, len(cfg.Contexts))
}
if len(cfg.Envs) == 0 {
fmt.Printf(" [%s] envs configured\n", fail)
errs++
status = fail
} else {
fmt.Printf(" [%s] envs configured (%d)\n", pass, len(cfg.Envs))
}
if cfg.ContextTemplate != "" {
ctx := cfg.ResolveContext(cfg.Envs[0], cfg.Contexts[0])
fmt.Printf(" [%s] context_template resolves to: %s\n", pass, ctx)
} else {
fmt.Printf(" [%s] context_template is empty\n", fail)
errs++
status = fail
}
if cfg.SecretNameTemplate != "" && len(cfg.Envs) > 0 {
secret := cfg.ResolveSecretName("example-ns", cfg.Envs[0])
fmt.Printf(" [%s] secret_name_template resolves to: %s\n", pass, secret)
}
}
}
// --- Connections ---
fmt.Println("\nConnections:")
if kubeexec.CheckTool("kubectl") == nil {
err := kubeexec.CheckAWSAuth()
if err == nil {
fmt.Printf(" [%s] kubectl cluster reachable\n", pass)
} else {
// Not fatal — cluster might be unreachable from this machine
fmt.Printf(" [%s] kubectl cluster: %v\n", warn, err)
}
}
if kubeexec.CheckTool("aws") == nil {
err := kubeexec.CheckAWSAuth()
if err == nil {
fmt.Printf(" [%s] AWS credentials valid\n", pass)
} else {
fmt.Printf(" [%s] AWS credentials: %v\n", warn, err)
}
} else {
fmt.Printf(" [%s] AWS credentials (aws CLI not installed)\n", warn)
}
// --- Summary ---
fmt.Println()
if status == pass {
fmt.Println("All checks passed. kctl-tui is ready to use.")
} else {
fmt.Printf("%d error(s) found. Fix the issues above and re-run: kctl-tui doctor\n", errs)
os.Exit(1)
}
return nil
}
+73 -7
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package main package main
import ( import (
@@ -51,6 +54,7 @@ type panelModel struct {
awsValues map[string]string awsValues map[string]string
k8sValues map[string]string k8sValues map[string]string
diffEntries []kctl.SecretDiffEntry diffEntries []kctl.SecretDiffEntry
diffOffset int // scroll position for diff table
message string message string
err error err error
@@ -76,13 +80,19 @@ func newPanelModel(context, ns, team string) *panelModel {
ti.Focus() ti.Focus()
cfgPath, _ := config.DefaultPath() cfgPath, _ := config.DefaultPath()
cfg, _ := config.Load(cfgPath) cfg, loadErr := config.Load(cfgPath)
l := list.New(nil, list.NewDefaultDelegate(), 0, 0) l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
m := &panelModel{context: context, ns: ns, team: team, cfg: cfg, step: stepEnvMenu, list: l, input: ti} m := &panelModel{context: context, ns: ns, team: team, cfg: cfg, step: stepEnvMenu, list: l, input: ti}
m.showEnvMenu() if loadErr != nil {
m.err = fmt.Errorf("config load failed: %w", loadErr)
m.step = stepError
} else {
m.showEnvMenu()
}
return m return m
} }
@@ -139,6 +149,14 @@ func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleEsc() return m.handleEsc()
case "enter": case "enter":
return m.handleEnter() return m.handleEnter()
case "up", "k":
if m.step == stepDiffResult {
return m.scrollDiff(-1)
}
case "down", "j":
if m.step == stepDiffResult {
return m.scrollDiff(1)
}
} }
} }
@@ -250,6 +268,9 @@ func (m *panelModel) fromActionMenu() (tea.Model, tea.Cmd) {
// interactively instead of letting the user hit a confusing failure // interactively instead of letting the user hit a confusing failure
// several steps later. // several steps later.
func (m *panelModel) checkAWSAuthAndProceed() (tea.Model, tea.Cmd) { func (m *panelModel) checkAWSAuthAndProceed() (tea.Model, tea.Cmd) {
if err := kubeexec.CheckTool("aws"); err != nil {
return m.showError(err)
}
if err := kubeexec.CheckAWSAuth(); err != nil { if err := kubeexec.CheckAWSAuth(); err != nil {
m.err = err m.err = err
m.list.SetItems([]list.Item{ m.list.SetItems([]list.Item{
@@ -358,7 +379,8 @@ func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
} }
m.k8sValues = k8sValues m.k8sValues = k8sValues
m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues) m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
m.message = renderDiffTable(m.currentEnv, m.awsSecretName, m.k8sSecretName, m.diffEntries) m.diffOffset = 0
m.message = renderDiffTable(m.currentEnv, m.awsSecretName, m.k8sSecretName, m.diffEntries, m.diffOffset, 0)
if anyMismatch(m.diffEntries) { if anyMismatch(m.diffEntries) {
m.list.SetItems([]list.Item{ m.list.SetItems([]list.Item{
@@ -373,16 +395,52 @@ func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func renderDiffTable(env, awsSecretName, k8sSecretName string, entries []kctl.SecretDiffEntry) string { func (m *panelModel) scrollDiff(delta int) (tea.Model, tea.Cmd) {
newOff := m.diffOffset + delta
if newOff < 0 {
newOff = 0
}
maxOff := len(m.diffEntries) - 1
if maxOff < 0 {
maxOff = 0
}
if newOff > maxOff {
newOff = maxOff
}
m.diffOffset = newOff
m.message = renderDiffTable(m.currentEnv, m.awsSecretName, m.k8sSecretName, m.diffEntries, m.diffOffset, 0)
return m, nil
}
func renderDiffTable(env, awsSecretName, k8sSecretName string, entries []kctl.SecretDiffEntry, offset, visibleHeight int) string {
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "env: %s AWS secret: %s Kubernetes secret: %s\n\n", env, awsSecretName, k8sSecretName) fmt.Fprintf(&b, "env: %s AWS secret: %s Kubernetes secret: %s\n\n", env, awsSecretName, k8sSecretName)
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", "KEY", "AWS", "KUBERNETES", "STATUS") fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", "KEY", "AWS", "KUBERNETES", "STATUS")
for _, e := range entries { start := offset
if start > len(entries) {
start = len(entries)
}
end := len(entries)
if visibleHeight > 0 && start+visibleHeight < end {
end = start + visibleHeight
}
for _, e := range entries[start:end] {
status := "OK" status := "OK"
if !e.Match { if !e.Match {
status = "MISMATCH" status = "MISMATCH"
} }
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", e.Key, truncate(e.Left, 20), truncate(e.Right, 20), status) left := e.Left
if e.LeftBin {
left = fmt.Sprintf("<binary %d bytes>", len(e.Left))
}
right := e.Right
if e.RightBin {
right = fmt.Sprintf("<binary %d bytes>", len(e.Right))
}
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", e.Key, truncate(left, 20), truncate(right, 20), status)
}
if len(entries) > 0 {
fmt.Fprintf(&b, "\n Showing %d-%d of %d fields (j/k or arrow keys to scroll)", start+1, end, len(entries))
} }
return b.String() return b.String()
} }
@@ -410,7 +468,15 @@ func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
} }
func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) { func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
name := m.input.Value() name := strings.TrimSpace(m.input.Value())
if name == "" {
return m.showError(fmt.Errorf("ExternalSecret name must not be empty"))
}
for _, r := range name {
if r < 0x20 || r > 0x7e || r == '/' || r == ' ' {
return m.showError(fmt.Errorf("ExternalSecret name contains invalid character: %q", r))
}
}
ts := time.Now().Unix() ts := time.Now().Unix()
_, err := kubeexec.AnnotateForceSync(m.resolvedContext(), m.ns, name, ts) _, err := kubeexec.AnnotateForceSync(m.resolvedContext(), m.ns, name, ts)
if err != nil { if err != nil {
+30 -4
View File
@@ -1,10 +1,36 @@
module github.com/skoelle/kctl-tui module github.com/skoelle/kctl-tui
go 1.22 go 1.24.2
require ( require (
github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbles v0.21.1
github.com/charmbracelet/bubbletea v1.1.1 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.0.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.5 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
+79
View File
@@ -0,0 +1,79 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0=
github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk=
github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY=
github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk=
github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4=
github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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=
+74
View File
@@ -0,0 +1,74 @@
# Install script for kctl-tui on Windows.
# Downloads the latest GitHub release binary matching the current architecture
# and installs it to your PATH.
#
# Usage (PowerShell):
# irm https://raw.githubusercontent.com/skoelle/kctl-tui/main/install.ps1 | iex
#
# Or save and run locally:
# .\install.ps1
#
# Requires: PowerShell 5.1+, internet access.
$ErrorActionPreference = "Stop"
$Repo = "skoelle/kctl-tui"
$BinName = "kctl-tui"
# --- Detect architecture ---
$arch = $env:PROCESSOR_ARCHITECTURE
switch ($arch) {
"AMD64" { $goarch = "amd64" }
"ARM64" { $goarch = "arm64" }
default {
Write-Error "Unsupported architecture: $arch"
exit 1
}
}
# --- Determine install directory ---
$installDir = "$env:USERPROFILE\bin"
if (-not (Test-Path $installDir)) {
New-Item -ItemType Directory -Path $installDir | Out-Null
}
# Add to PATH if not already there
$currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($currentPath -notlike "*$installDir*") {
[Environment]::SetEnvironmentVariable("Path", "$currentPath;$installDir", "User")
$env:Path = "$env:Path;$installDir"
Write-Host "Added $installDir to your PATH."
}
# --- Query GitHub API for latest release ---
Write-Host "Detecting latest release for $Repo ..."
try {
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -UseBasicParsing
} catch {
Write-Error "Failed to reach the GitHub API (network error). Check your internet connection and try again."
exit 1
}
$tag = $release.tag_name
if (-not $tag) {
Write-Error "Could not find a published release for $Repo. No release has been tagged yet."
exit 1
}
Write-Host "Latest release: $tag"
# --- Download binary ---
$asset = "kctl-tui-windows-${goarch}.exe"
$url = "https://github.com/$Repo/releases/download/$tag/$asset"
$outFile = "$installDir\$BinName.exe"
Write-Host "Downloading $asset ($tag) ..."
try {
Invoke-WebRequest -Uri $url -OutFile $outFile -UseBasicParsing
} catch {
Write-Error "Download failed: $_"
exit 1
}
Write-Host "Installed $BinName to $outFile"
Write-Host "Done. Run '$BinName' to get started."
+2
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
# Licensed under the MIT License. See LICENSE file in project root for details.
# Install script for kctl-tui. # Install script for kctl-tui.
# Downloads the latest GitHub release binary matching the current OS/arch # Downloads the latest GitHub release binary matching the current OS/arch
# and installs it to /usr/local/bin (or $INSTALL_DIR if set). # and installs it to /usr/local/bin (or $INSTALL_DIR if set).
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
// Package config loads the user-specific, non-versioned kctl-tui // Package config loads the user-specific, non-versioned kctl-tui
// configuration (contexts, envs, templates) from a YAML file. // configuration (contexts, envs, templates) from a YAML file.
package config package config
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package config package config
import ( import (
+31 -5
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl package kctl
import "sort" import "sort"
@@ -5,10 +8,27 @@ import "sort"
// SecretDiffEntry represents the comparison of one key between two secret // SecretDiffEntry represents the comparison of one key between two secret
// sources (e.g. AWS Secrets Manager vs. a Kubernetes Secret). // sources (e.g. AWS Secrets Manager vs. a Kubernetes Secret).
type SecretDiffEntry struct { type SecretDiffEntry struct {
Key string Key string
Left string // e.g. the AWS Secrets Manager value Left string // e.g. the AWS Secrets Manager value
Right string // e.g. the decoded Kubernetes secret value Right string // e.g. the decoded Kubernetes secret value
Match bool Match bool
LeftBin bool // true if Left contains non-printable (binary) data
RightBin bool // true if Right contains non-printable (binary) data
}
// IsBinary reports whether s contains non-printable bytes (i.e. is likely
// binary data rather than human-readable text). Control characters below
// space (0x20) are excluded, except for common whitespace (\t, \n, \r).
func IsBinary(s string) bool {
for _, r := range s {
if r > 0x7f {
return true
}
if r < 0x20 && r != '\t' && r != '\n' && r != '\r' {
return true
}
}
return false
} }
// DiffSecretValues compares two key/value maps and returns a sorted list of // DiffSecretValues compares two key/value maps and returns a sorted list of
@@ -35,7 +55,13 @@ func DiffSecretValues(left, right map[string]string) []SecretDiffEntry {
for _, k := range keys { for _, k := range keys {
l := left[k] l := left[k]
r := right[k] r := right[k]
result = append(result, SecretDiffEntry{Key: k, Left: l, Right: r, Match: l == r}) lb := IsBinary(l)
rb := IsBinary(r)
match := l == r
if lb || rb {
match = l == r
}
result = append(result, SecretDiffEntry{Key: k, Left: l, Right: r, Match: match, LeftBin: lb, RightBin: rb})
} }
return result return result
} }
+43
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl package kctl
import "testing" import "testing"
@@ -57,3 +60,43 @@ func TestDiffSecretValues_EmptyMaps(t *testing.T) {
t.Fatalf("expected no mismatch for empty maps") t.Fatalf("expected no mismatch for empty maps")
} }
} }
func TestIsBinary_Plaintext(t *testing.T) {
if IsBinary("hello world") {
t.Fatal("expected plaintext to not be binary")
}
if IsBinary("line1\nline2\ttab") {
t.Fatal("expected newline/tab to not be binary")
}
}
func TestIsBinary_BinaryData(t *testing.T) {
if !IsBinary("hello\x00world") {
t.Fatal("expected null byte to be binary")
}
if !IsBinary("key=\xff\xfe") {
t.Fatal("expected non-ASCII bytes to be binary")
}
}
func TestDiffSecretValues_BinaryDetection(t *testing.T) {
left := map[string]string{"ok": "text", "bin": "data\x00here"}
right := map[string]string{"ok": "text", "bin": "data\x00here"}
entries := DiffSecretValues(left, right)
for _, e := range entries {
if e.Key == "bin" {
if !e.LeftBin || !e.RightBin {
t.Fatalf("expected binary flags set for key 'bin', got LeftBin=%v RightBin=%v", e.LeftBin, e.RightBin)
}
if !e.Match {
t.Fatalf("expected binary values to match")
}
}
if e.Key == "ok" {
if e.LeftBin || e.RightBin {
t.Fatalf("expected binary flags unset for key 'ok'")
}
}
}
}
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl package kctl
import "sort" import "sort"
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl package kctl
import ( import (
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl package kctl
import "strings" import "strings"
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl package kctl
import "testing" import "testing"
+34 -3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
// Package kubeexec wraps kubectl/aws-cli invocations used by kctl-tui. // Package kubeexec wraps kubectl/aws-cli invocations used by kctl-tui.
// All functions here have side effects (they run external processes) and // All functions here have side effects (they run external processes) and
// are therefore not covered by unit tests; the pure logic they depend on // are therefore not covered by unit tests; the pure logic they depend on
@@ -19,12 +22,27 @@ import (
) )
func runOutput(name string, args ...string) (string, error) { func runOutput(name string, args ...string) (string, error) {
logCmd(name, args...)
cmd := exec.Command(name, args...) cmd := exec.Command(name, args...)
out, err := cmd.CombinedOutput() var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil { if err != nil {
return "", fmt.Errorf("%s %s failed: %w\n%s", name, strings.Join(args, " "), err, string(out)) logErr(name, err)
prefix := fmt.Sprintf("%s %s failed: %v", name, strings.Join(args, " "), err)
msg := prefix
if s := strings.TrimSpace(stderr.String()); s != "" {
msg += "\n" + s
}
if s := strings.TrimSpace(stdout.String()); s != "" {
msg += "\n" + s
}
return "", fmt.Errorf("%s", msg)
} }
return strings.TrimSpace(string(out)), nil out := strings.TrimSpace(stdout.String())
logOutput(name, out)
return out, nil
} }
// kubectlArgs prepends a --context flag when context is non-empty. // kubectlArgs prepends a --context flag when context is non-empty.
@@ -162,3 +180,16 @@ func RunAWSLogin(loginCommand string) *exec.Cmd {
} }
return exec.Command(parts[0], parts[1:]...) return exec.Command(parts[0], parts[1:]...)
} }
// CheckTool verifies that a named executable is available in PATH.
// Returns nil if found, or a descriptive error if not.
func CheckTool(name string) error {
_, err := exec.LookPath(name)
if err == nil {
return nil
}
if name == "tmux" {
return fmt.Errorf("%q not found in PATH — install tmux (Linux/macOS) or psmux (Windows: scoop install psmux or cargo install psmux)", name)
}
return fmt.Errorf("%q not found in PATH — please install it first", name)
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kubeexec
import (
"fmt"
"io"
"strings"
"sync"
)
var (
verbose bool
logOut io.Writer = io.Discard
mu sync.Mutex
)
// SetVerbose enables or disables debug logging of executed commands.
// When enabled, commands and their outputs are written to the provided
// writer (typically os.Stderr). When disabled (the default), all logging
// is discarded.
func SetVerbose(enabled bool, w io.Writer) {
mu.Lock()
defer mu.Unlock()
verbose = enabled
if w != nil {
logOut = w
}
}
// VerboseLog writes a message to the verbose log if enabled.
func VerboseLog(format string, args ...interface{}) {
mu.Lock()
defer mu.Unlock()
if !verbose {
return
}
fmt.Fprintf(logOut, format, args...)
}
func logCmd(name string, args ...string) {
mu.Lock()
defer mu.Unlock()
if !verbose {
return
}
fmt.Fprintf(logOut, "[cmd] %s %s\n", name, strings.Join(args, " "))
}
func logOutput(name string, output string) {
mu.Lock()
defer mu.Unlock()
if !verbose {
return
}
if output != "" {
fmt.Fprintf(logOut, "[out] %s: %s\n", name, output)
}
}
func logErr(name string, err error) {
mu.Lock()
defer mu.Unlock()
if !verbose {
return
}
fmt.Fprintf(logOut, "[err] %s: %v\n", name, err)
}
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"schedule": ["before 6am on Monday"],
"packageRules": [
{
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "GitHub Actions",
"automerge": true
},
{
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["major"],
"groupName": "GitHub Actions (major)",
"labels": ["major-update"],
"automerge": false
},
{
"matchManagers": ["gomod"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "Go dependencies",
"automerge": true
},
{
"matchManagers": ["gomod"],
"matchUpdateTypes": ["major"],
"groupName": "Go dependencies (major)",
"labels": ["major-update"],
"automerge": false
},
{
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
},
{
"matchUpdateTypes": ["major"],
"labels": ["major-update"],
"automerge": false
}
]
}