Compare commits

...
83 Commits
Author SHA1 Message Date
stefankoelle 73cc510347 release v0.3.6
Windows Terminal fullscreen and keybinding improvements.

- Add -F flag to wt.exe to start in fullscreen mode
- Disable 'q' keybinding in panel when using wt mode — prevents accidental quit (ctrl+c remains available)
2026-09-16 23:19:38 +02:00
stefankoelle d17eb6971c feat: add fullscreen and disable q key in Windows Terminal mode
- Add -F flag to wt.exe to start in fullscreen mode
- Disable 'q' keybinding in panel when using wt mode (ctrl+c remains)
2026-09-16 23:18:45 +02:00
stefankoelle fb4c1be12b release v0.3.5
Disable Quit option in Windows Terminal mode.

- Hide 'Quit (closes this tmux session)' in env menu when using wt mode — prevents confusion as wt windows are independent
- Esc on env menu stays on env menu in wt mode (no tmux kill attempted)
2026-09-16 22:46:26 +02:00
stefankoelle 9e3fefb71a feat: disable Quit option in Windows Terminal mode
- Hide 'Quit (closes this tmux session)' in env menu when using wt mode
- Esc on env menu stays on env menu in wt mode (no tmux kill attempted)
2026-09-16 22:41:34 +02:00
stefankoelle 2d382441a1 release v0.3.4
Improved Windows Terminal layout and update error handling.

- Split ratios: panel 25%, k9sA 37.5%, k9sB 37.5% (was 50/25/25) — k9s panes now get more space
- Update failures now exit with error code 1 instead of continuing silently with the old version
2026-09-16 22:21:56 +02:00
stefankoelle ad7d8534ab feat: improve Windows Terminal split ratios and update error handling
- Split ratios: panel 25%, k9sA 37.5%, k9sB 37.5% (was 50/25/25)
- Update failures now exit with error code 1 instead of continuing with old version
2026-09-16 22:20:42 +02:00
stefankoelle a0b937e882 release v0.3.3
Fix Windows Terminal mode issues.

- Fix split direction: use -H for top/bottom layout (opposite of tmux)
- Fix command quoting: use cmd /c to avoid Windows file-not-found errors
- Remove confusing tmux session closed message in wt mode
2026-09-16 21:44:37 +02:00
stefankoelle d32ccd5952 fix: avoid confusing tmux message in wt mode
Use c.Start() instead of tea.ExecProcess in startWtSession() to prevent
tmux session closed message from appearing in the main window.
2026-09-16 21:44:23 +02:00
stefankoelle 3729a0301d release v0.3.2
Windows Terminal native split-pane option.

- Add multiplexer config option to switch between tmux/psmux (default) and Windows Terminal's native split-pane
- Avoids the psmux focus-freeze issue when switching away from the terminal window on Windows
- Config: multiplexer: "tmux" (default) or "wt" for Windows Terminal
2026-09-16 21:12:26 +02:00
stefankoelle a1760b7b01 feat: add Windows Terminal native split-pane option
Add multiplexer config option to switch between tmux/psmux (default) and
Windows Terminal's native split-pane on Windows. This avoids the
psmux focus-freeze issue when switching away from the terminal window.

- Add Multiplexer field to config with MultiplexerBackend() getter
- Add startWtSession() using wt.exe split-pane command
- Dispatch startTmuxSession() based on OS and config
- Document option in config.example.yaml
2026-09-16 21:10:36 +02:00
stefankoelle bd23403e6c release v0.3.1
Compact menu layout and cross-env namespace discovery.

- Reduce menu item height from 3 rows to 1 (no description, no spacing) for denser display
- Merge namespaces from all configured envs so namespace-only-in-prod is no longer missing
2026-09-16 12:21:41 +02:00
stefankoelle 6a0405640d feat: compact menu layout and merge namespaces across all envs
- Reduce menu item height from 3 rows to 1 (no description, no spacing)
- Merge namespaces from all configured envs so namespace-only-in-prod is no longer missing
2026-09-16 12:18:17 +02:00
stefankoelle c6880e92ce release v0.3.0
Self-update support, config editing, and ExternalSecret CRD fixes.

- Self-update command (kctl-tui update): download and install the latest release directly from GitHub
- Startup update check: interactive prompt on launch when a newer version is available (opt-out via auto_update_check: false in config)
- Config edit command (kctl-tui config edit): open ~/.kctl-tui/config.yaml in your default editor
- ExternalSecret CRD fix: use the correct CRD name when force-syncing
- CI: upgrade to Go 1.25
2026-09-16 11:53:08 +02:00
stefankoelle 209e93646c feat: add config edit command to open config in editor
New subcommand 'kctl-tui config edit' opens ~/.kctl-tui/config.yaml
in the user's editor. Respects VISUAL/EDITOR env vars, falls back to
notepad on Windows and vim on Linux/macOS. Creates the config directory
and a starter file if they don't exist yet.
2026-09-16 11:49:40 +02:00
stefankoelle 720064454b fix: use correct ExternalSecret CRD name for force-sync annotation
The force-sync pre-filled the ExternalSecret object name with the
Kubernetes secret name (e.g. job-apply-common-secrets), but the
kubectl annotate command must target the ExternalSecret CRD object
(e.g. job-apply). Added external_secret_name_template config option
with fallback to k8s_secret_name_template.
2026-09-16 11:49:35 +02:00
Stefan Koelle 205f8906df Merge pull request #3 from skoelle/feature/self-update
feat: add self-update command
2026-09-16 11:20:45 +02:00
stefankoelle c0ad7e803e feat: add config opt-out, TTY check, and config-first update flow
- Add auto_update_check config field (defaults to true)
- Load config before update check, skip if auto_update_check is false
- Add TTY check before interactive prompt (skip in non-TTY environments)
- Update config.example.yaml with new option documentation
2026-08-22 10:44:01 +02:00
stefankoelle 7f2c9b2ba8 refactor: address code review findings for self-update
- go.mod: 1.25.12 → 1.25 (fix nonexistent patch version)
- Remove init() log mutation, pass logger explicitly via initUpdater()
- Add 10s HTTP timeout via context.WithTimeout on API calls
- Fix version comparison using proper semver (current.LessThan(newVer))
- Extract shared initUpdater() helper to eliminate duplicate creation
- Dev build warning now goes to stderr consistently
- Exit with code 0 after successful interactive update (binary replaced)
2026-08-22 10:08:48 +02:00
stefankoelle fed2b44c28 fix: upgrade CI to Go 1.25 and document self-update feature
- Upgrade go-version from 1.24 to 1.25 in build.yml (required by go-selfupdate v1.6.0)
- Add kctl-tui update command and interactive startup check to README.md
- Document new features in CHANGELOG.md
- Update Go version requirement to 1.25+ in README.md and CONTRIBUTING.md
- Add Phase 5 (Self-update) to PLAN.md roadmap
2026-08-22 09:50:27 +02:00
stefankoelle d89efc55d9 feat: add interactive update check on startup
When starting kctl-tui without a subcommand, check GitHub Releases
for a newer version and prompt the user to update.

- Silent skip for dev builds and network errors
- Prompt: 'Update now? [y/N]'
- On 'y': apply update, then start TUI
- On 'N'/Enter: start TUI immediately
2026-08-22 09:33:41 +02:00
stefankoelle d743422d28 feat: add self-update command
Add 'kctl-tui update' subcommand that checks GitHub Releases for
new versions and replaces the running binary.

Uses github.com/creativeprojects/go-selfupdate for safe binary
replacement with automatic OS/arch detection.

Supports --verbose flag for detailed update progress logging.
2026-08-22 09:25:28 +02:00
stefankoelle b9439e428a README.md 2026-08-14 23:52:41 +02:00
stefankoelle 4afb20c740 create release command fix2 2026-08-11 14:20:00 +02:00
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
Stefan Koelle 4bb3507aec Use separate templates for AWS secret ID and Kubernetes secret name 2026-08-09 14:39:32 +02:00
Stefan Koelle 145288971a Add separate k8s_secret_name_template, since Kubernetes secret names follow a different pattern than AWS secret names 2026-08-09 14:36:44 +02:00
Stefan Koelle 75e0b832a2 fix: compute Kubernetes secret name from secret_name_template too, no more manual prompt 2026-08-09 14:31:24 +02:00
Stefan Koelle 0f889de86f docs: update README for the redesigned env-menu control pane and template-based config 2026-08-09 14:14:34 +02:00
Stefan Koelle ff898f727a Redesign panel: env-first menu (quit/beta/prod, each with secrets sync + redeploy), templated AWS secret ID instead of listing/region input 2026-08-09 14:05:33 +02:00
Stefan Koelle f7606011bb Redesign full-mode navigation: start at team selection with default context, resolve k9s panes from config envs instead of context-pairs 2026-08-09 14:02:55 +02:00
Stefan Koelle c02e145e4a Rework kubeexec to take explicit --context per call instead of mutating global kubectl state; drop functions superseded by config templates 2026-08-09 14:01:06 +02:00
Stefan Koelle 929b073382 Redesign config schema: contexts + envs + AWS region + secret/context templates instead of live kubectl discovery and context-pairs 2026-08-09 13:58:19 +02:00
Stefan Koelle 3af468de98 Remove context-pair tests, superseded by env-template based context resolution 2026-08-09 13:54:01 +02:00
Stefan Koelle 0a607bab28 Remove context-pair logic, superseded by env-template based context resolution 2026-08-09 13:51:36 +02:00
Stefan Koelle 1f87cdbf08 Add pure template-resolution logic (kctl.ResolveTemplate) with unit tests 2026-08-09 13:49:38 +02:00
Stefan Koelle 79c861682b Add AWS auth check before secrets workflow, with interactive SSO login prompt on expired session 2026-08-09 12:40:02 +02:00
Stefan Koelle c229f4d5d8 Add AWS auth check (sts get-caller-identity) and configurable SSO login command 2026-08-09 12:37:51 +02:00
Stefan Koelle 9daba2a3f0 fix: show a visible error screen instead of silently swallowing errors on resetToMenu 2026-08-09 12:31:43 +02:00
Stefan Koelle 4be10b6432 docs: update PLAN.md to reflect the redesigned secrets workflow (list + diff-all-fields + whole-secret force-sync) 2026-08-09 12:15:05 +02:00
Stefan Koelle e57d58aecc Redesign secrets workflow: pick AWS secret from a list, diff all fields at once, offer force-sync for the whole secret 2026-08-09 12:14:01 +02:00
Stefan Koelle b7d553c966 Add ListAWSSecrets and GetSecretAllFields for the redesigned secrets diff flow 2026-08-09 12:10:59 +02:00
Stefan Koelle 793c28afff Add pure secret-diff logic (kctl.DiffSecretValues) with unit tests 2026-08-09 12:09:25 +02:00
stefankoelle 9162eebccc fix install.sh 2026-08-09 12:03:05 +02:00
Stefan Koelle 26b5754f60 fix: use even-vertical tmux layout and remain-on-exit so panes stack correctly and errors stay visible 2026-08-09 12:01:50 +02:00
Stefan Koelle 76a389f52d install.sh: prevent silent abort from pipefail in tag_name extraction, add diagnostics 2026-08-09 11:57:06 +02:00
Stefan Koelle 73b38ef52c install.sh: read GitHub API response into a variable instead of a temp file (fixes curl exit 23) 2026-08-09 11:50:49 +02:00
Stefan Koelle c10b553b48 install.sh: fail with a clear message when no GitHub release exists yet 2026-08-09 11:48:06 +02:00
37 changed files with 2795 additions and 593 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.25"
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.25"
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
+6
View File
@@ -0,0 +1,6 @@
title: "kctl-tui"
emoji: "🐳"
category: code
subcategory: "Dev Tools"
status: active
stack: [Go, Bubbletea, Bubbles, Lipgloss, YAML]
+36
View File
@@ -0,0 +1,36 @@
---
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 separately (pushing both in one step causes GitHub Actions to skip the release workflow):
- Run `git push origin main` (or current branch)
- Run `git push origin v$ARGUMENTS`
7. Confirm success with the version number
+65
View File
@@ -0,0 +1,65 @@
# 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]
### Added
- `kctl-tui update` — self-update command that downloads the latest release
from GitHub and replaces the running binary
- Automatic update check on startup — prompts to update interactively when
a newer version is available
## [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.25+
- 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
+124 -39
View File
@@ -10,63 +10,148 @@ is still open. For the full requirements, see [SPEC.md](SPEC.md).
cross-platform build matrix (linux/darwin/windows x amd64/arm64) that cross-platform build matrix (linux/darwin/windows x amd64/arm64) that
attaches binaries to GitHub Releases on version tags. attaches binaries to GitHub Releases on version tags.
- [x] `install.sh` for Linux/macOS/WSL, downloading the latest release - [x] `install.sh` for Linux/macOS/WSL, downloading the latest release
asset. asset, with clear diagnostics if no release exists yet or the GitHub
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`) and namespace/label template resolution (`ResolveTemplate`), namespace/label filtering
filtering (`DistinctLabelValues`, `NamespacesForLabelValue`). (`DistinctLabelValues`, `NamespacesForLabelValue`), and secret diffing
- [x] `internal/config`: YAML config loading (`context_pairs`, (`DiffSecretValues`, `AnyMismatch`).
`team_label_key`), with safe defaults when no config file exists yet. - [x] `internal/config`: YAML config loading with template-based context
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, secret (namespaces, deployments, rollout restart/status, fetching AWS
read, ExternalSecret annotation). secrets by template-resolved ID, reading all fields of a Kubernetes
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
context/namespace. context/namespace.
- [x] On confirming a namespace, "full" mode launches the 3-pane `tmux` - [x] On confirming a namespace, "full" mode launches the 3-pane `tmux`
session (control pane + two `k9s` panes) via `tea.ExecProcess` and session (control pane + two `k9s` panes, `even-vertical` layout,
resumes at the namespace screen once the session ends. `remain-on-exit` so a crashing control pane stays visible) via
- [x] `cmd/kctl-tui` "panel" mode: menu for Redeploy and the AWS/Kubernetes `tea.ExecProcess` and resumes at the namespace screen once the
secrets diff + force-sync wizard, with `Esc` closing the whole tmux session ends.
session (`tmux kill-session`). - [x] `cmd/kctl-tui` "panel" mode:
- Redeploy: pick a deployment from a list, confirm, then
`rollout restart` + `rollout status`.
- Secrets: AWS auth check with interactive SSO login fallback,
then automatically resolve the AWS secret ID (from
`secret_name_template`) and Kubernetes secret name (from
`k8s_secret_name_template`), fetch both, diff **every field**
in one table (key / AWS value / Kubernetes value / match status).
If any field differs, offer a single force-sync request for the
**whole secret** (one ExternalSecret annotation).
- `Esc` closes the whole tmux session (`tmux kill-session`).
## Phase 2 — Hardening (open) ## Phase 2 — Hardening (done)
- [ ] Replace the hand-rolled AWS secret JSON parsing/`fmt.Sprintf` value - [x] Handle non-JSON AWS secrets and Kubernetes secrets with binary
formatting with a proper typed decode, and handle secrets that are (non-UTF8) values more gracefully in the diff table.
plain strings rather than JSON.
- [ ] 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.
secret ID/region/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). `kubectl`/`aws` calls.
- [ ] Structured logging / `--verbose` flag for troubleshooting failed - [x] Paginate/scroll the secrets diff table for secrets with many fields.
`kubectl` calls.
## 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`.
## Phase 5 — Self-update (done)
- [x] `kctl-tui update` — self-update command using `go-selfupdate` library.
Downloads the matching OS/arch asset from GitHub Releases and replaces
the running binary atomically.
- [x] Interactive update check on startup — queries GitHub Releases in the
background, prompts the user to update when a newer version is found.
---
## 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
+164 -75
View File
@@ -1,83 +1,104 @@
# kctl-tui # 🚀 kctl-tui
A small terminal entry point for everyday Kubernetes work: pick a context, A small terminal entry point for everyday Kubernetes work: pick a context
a team, and a namespace once, then drive status (via k9s), rollout and a namespace once, then drive rollout restarts and an AWS Secrets
restarts, and an AWS Secrets Manager <-> Kubernetes Secret diff/force-sync Manager <-> Kubernetes Secret diff/force-sync per environment from one
workflow from one place instead of retyping long `kubectl` commands. place instead of retyping long `kubectl` commands.
## Why ## Why
Working with several clusters, many namespaces per team, and paired Working with several clusters, many namespaces per team, and paired
environments (e.g. staging/production) quickly turns into a lot of repeated environments (e.g. beta/prod) quickly turns into a lot of repeated typing
typing with plain `kubectl`/`k9s`. kctl-tui adds: with plain `kubectl`/`k9s`. kctl-tui adds:
- A guided **context -> team -> namespace** selection with sensible - 🎯 A guided **context -> team -> namespace** selection that starts
defaults (the currently active context/namespace is pre-selected). directly at team selection (using a configured default context), with
- Namespace grouping by an arbitrary, configurable **label** instead of the context screen just one `Esc` away.
- 🏷️ Namespace grouping by an arbitrary, configurable **label** instead of
scrolling through every namespace in the cluster. scrolling through every namespace in the cluster.
- A **3-pane view** (via `tmux`): one control pane for actions, two status - 🖥️ A **3-pane view** (via `tmux`): one control pane for actions, two status
panes running `k9s` for the current namespace across two related panes running `k9s` for the current namespace across your two
contexts. configured environments (e.g. beta/prod), shown side by side.
- A guided **rollout restart** that lists deployments instead of requiring - 📋 A control-pane menu organized **by environment**: pick beta or prod,
you to know/type the exact deployment name. then Secrets sync or Redeploy for that environment specifically.
- A guided **AWS Secrets Manager vs. Kubernetes Secret** comparison, - 🔐 AWS Secrets Manager secret IDs and Kubernetes context names/ARNs are
including an optional ExternalSecret force-sync annotation. **computed from configurable templates** (namespace + environment),
instead of listing secrets or discovering contexts live from
`kubectl`/`aws-cli`.
- 🔄 A guided **AWS Secrets Manager vs. Kubernetes Secret** comparison of
every field at once, with a force-sync request for the whole secret if
anything differs.
- 🔑 An **AWS auth check** before the secrets workflow, offering to run your
configured SSO login command interactively if the session has expired.
See [SPEC.md](SPEC.md) for the full requirements and design rationale, and See [SPEC.md](SPEC.md) for the full requirements and design rationale, and
[PLAN.md](PLAN.md) for the implementation roadmap and current status. [PLAN.md](PLAN.md) for the implementation roadmap and current status.
## How it works ## 🔧 How it works
``` ```
+--------------------------------------------------+ +--------------------------------------------------+
| Control pane: kctl-tui panel | | Control pane: kctl-tui panel |
| -> Redeploy, Secrets diff/force-sync | | -> 1) Quit 2) beta 3) prod |
| each with: a) Secrets sync b) Redeploy |
+--------------------------------------------------+ +--------------------------------------------------+
| k9s --context <context-a> -n <namespace> | | k9s --context <resolved beta context> --namespace <ns> --command pods |
+--------------------------------------------------+ +--------------------------------------------------+
| k9s --context <context-b> -n <namespace> | | k9s --context <resolved prod context> --namespace <ns> --command pods |
+--------------------------------------------------+ +--------------------------------------------------+
``` ```
1. Run `kctl-tui`. It walks you through context, team, and namespace 1. Run `kctl-tui`. It loads your config, applies the default context, and
selection. jumps straight to team selection; press `Esc` there to pick a
2. Once a namespace is confirmed, it opens a `tmux` session with the layout different context first.
above and attaches to it. 2. Pick a team (namespace label filter), then a namespace.
3. Inside the control pane you can trigger a rollout restart or compare/ 3. It opens a `tmux` session with the layout above: the control pane runs
force-sync a secret. The two status panes keep showing live pod state this binary in "panel" mode, the two status panes run `k9s` against
via `k9s`, so there is no separate "status" menu entry. your first two configured environments (e.g. beta and prod), resolved
4. Pressing `Esc` in the control pane closes the whole `tmux` session from `context_template`.
(including both `k9s` panes) and returns you to the namespace 4. In the control pane, pick an environment, then Secrets sync or
selection. Redeploy for that environment. `Esc` goes back one level (action menu
5. Pressing `Tab` in the control pane switches both status panes to the -> environment menu -> closes the whole tmux session, including both
paired context configured in `context_pairs` (see Configuration), `k9s` panes, and returns you to namespace selection).
keeping the same namespace.
## Requirements ## 📋 Requirements
- `kubectl`, configured with access to your cluster(s). - 🐳 `kubectl`, configured with access to your cluster(s) (the actual
- `k9s` (used for the two status panes). context names/ARNs are resolved from your `context_template`, see
- `tmux` (used for the 3-pane layout). On Windows, this means running Configuration below - they must already exist in your kubeconfig, e.g.
kctl-tui inside **WSL**`tmux` has no native Windows port. Native added via `aws eks update-kubeconfig`).
Windows Terminal has its own split-pane feature, but it cannot be - 👀 `k9s` (used for the two status panes).
scripted from inside a pane the way `tmux` can, so the automated 3-pane - 📺 `tmux` (used for the 3-pane layout). On **Linux/macOS**, install
layout and the `Tab`/`Esc` session handling described above are only `tmux` via your package manager. On **Windows**, install
fully supported under Linux/WSL. See SPEC.md section 3.6 for details. [psmux](https://github.com/marlocarlo/psmux) — a native,
- `aws` CLI, configured with credentials, only needed for the secrets tmux-compatible terminal multiplexer:
```powershell
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
workflow. workflow.
## Installation ## 📥 Installation
### Quick install (Linux/macOS/WSL) ### 🚀 Quick install (Linux/macOS/WSL)
```bash ```bash
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`.
### From source ### 🛠️ From source
```bash ```bash
git clone https://github.com/skoelle/kctl-tui.git git clone https://github.com/skoelle/kctl-tui.git
@@ -86,54 +107,122 @@ go build -o kctl-tui ./cmd/kctl-tui
sudo mv kctl-tui /usr/local/bin/ sudo mv kctl-tui /usr/local/bin/
``` ```
Requires Go 1.22+. Requires Go 1.25+.
### Prebuilt binaries ### 📦 Prebuilt binaries
Every tagged release (`vX.Y.Z`) is built for `linux`, `darwin`, and Every tagged release (`vX.Y.Z`) is built for `linux`, `darwin`, and
`windows`, each for `amd64` and `arm64`, via the GitHub Actions workflow in `windows`, each for `amd64` and `arm64`, via the GitHub Actions workflow in
[.github/workflows/build.yml](.github/workflows/build.yml). Download the [.github/workflows/build.yml](.github/workflows/build.yml). Download the
matching asset from the [Releases page](https://github.com/skoelle/kctl-tui/releases). matching asset from the [Releases page](https://github.com/skoelle/kctl-tui/releases).
## Configuration ## ⚙️ Configuration
Copy [config.example.yaml](config.example.yaml) to `~/.kctl-tui/config.yaml` Copy [config.example.yaml](config.example.yaml) to `~/.kctl-tui/config.yaml`
and adjust it to your own cluster setup: and adjust it to your own setup — or use `kctl-tui config edit` to open the
file directly in your editor (creates the file and directory if needed):
```yaml ```yaml
context_pairs: contexts:
- name: "example-environment-pair" - "internal"
contexts: - "external"
- "example-context-a" default_context: "internal"
- "example-context-b"
envs:
- "beta"
- "prod"
aws_region: "eu-central-1"
aws_account_id: "123456789012"
secret_name_template: "tf-{namespace}-{env}-secrets"
k8s_secret_name_template: "{namespace}-common-secrets"
external_secret_name_template: "{namespace}"
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"
aws_sso_login_command: "aws sso login"
``` ```
- `context_pairs`: groups of related `kubectl` contexts. `Tab` in the - 🌐 `contexts` / `default_context`: the top-level grouping the tool starts
control pane cycles through the contexts of whichever group the current from (e.g. a network boundary such as internal/external-facing
context belongs to. clusters). This is the outermost navigation level, one `Esc` above team
- `team_label_key`: the Kubernetes namespace label used to group selection.
- 🎛️ `envs`: the environments switchable from the control panel (e.g.
"beta"/"prod"). The **first two** entries are also used for the two k9s
status panes shown side by side.
- 🌍 `aws_region` / `aws_account_id`: used for AWS Secrets Manager calls and
to fill the `{account_id}` placeholder in `context_template`.
`123456789012` is a placeholder, not a real account.
- 🔑 `secret_name_template`: builds the AWS Secrets Manager secret ID from
the chosen namespace and environment. Placeholders: `{namespace}`,
`{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}`.
- 🎯 `external_secret_name_template`: builds the ExternalSecret CRD object
name to annotate when a force-sync is requested. This is often different
from the Kubernetes secret name because the ExternalSecret CRD and the
resulting Secret are separate objects (e.g. ExternalSecret `"job-apply"`
produces Secret `"job-apply-common-secrets"`). Falls back to
`k8s_secret_name_template` if omitted. Placeholders: `{namespace}`.
- 🔗 `context_template`: builds the actual kubectl context name/ARN from
region, account ID, environment, and context. Placeholders: `{region}`,
`{account_id}`, `{env}`, `{context}`. Adjust the literal parts (`tf-`,
`-1`, cluster naming, ARN shape) to match how your own clusters/contexts
are actually named — the resolved value must match an existing context
in your kubeconfig.
- 👥 `team_label_key`: the Kubernetes namespace label used to group
namespaces by team/ownership in the team-selection screen. This is namespaces by team/ownership in the team-selection screen. This is
entirely up to your organization's labeling convention; kctl-tui ships entirely up to your organization's labeling convention; kctl-tui ships
with no default team label of its own. with no default team label of its own.
- 🔐 `aws_sso_login_command`: run interactively if `aws sts
get-caller-identity` fails before the secrets workflow (e.g. an expired
SSO session). Defaults to `aws sso login`.
`~/.kctl-tui/config.yaml` is not part of this repository and should stay `~/.kctl-tui/config.yaml` is not part of this repository and should stay
that way — it typically contains your organization's internal context and that way — it typically contains your organization's internal account ID,
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
``` ```
## Development ## 📖 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 update # update to the latest release
kctl-tui doctor # check if all tools, config and connections are OK
kctl-tui config edit # open ~/.kctl-tui/config.yaml in your editor
kctl-tui config check # validate ~/.kctl-tui/config.yaml
kctl-tui panel --context=... --ns=... --team=... # internal (called by tmux)
```
When starting `kctl-tui` without a subcommand, a background check queries
GitHub Releases for a newer version. If one is found, you are prompted to
update interactively before the TUI starts.
## 🛠️ Development
```bash ```bash
go test ./... go test ./...
@@ -141,12 +230,12 @@ go vet ./...
go build ./cmd/kctl-tui go build ./cmd/kctl-tui
``` ```
Pure logic (context-pair matching, label filtering, config parsing) lives Pure logic (template resolution, label filtering, config parsing, secret
in `internal/kctl` and `internal/config` and is covered by unit tests. Code diffing) lives in `internal/kctl` and `internal/config` and is covered by
that shells out to `kubectl`/`aws`/`tmux` lives in `internal/kubeexec` and unit tests. Code that shells out to `kubectl`/`aws`/`tmux` lives in
in `cmd/kctl-tui` and is intentionally kept thin and untested, since it has `internal/kubeexec` and in `cmd/kctl-tui` and is intentionally kept thin
no meaningful behavior without a live cluster. 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.
+171 -56
View File
@@ -1,8 +1,14 @@
// 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"
"runtime"
"sort"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
@@ -20,9 +26,12 @@ const (
screenNamespace screenNamespace
) )
// fullModel drives the interactive context -> team -> namespace navigation // fullModel drives the interactive navigation. It starts directly at the
// and, once a namespace is chosen, launches the 3-pane tmux session // team-selection screen using the configured default context, and only
// (control pane + two k9s panes) via tea.ExecProcess. // shows the context screen when the user explicitly goes back via Esc.
// Once a namespace is chosen, it launches the 3-pane tmux session
// (control pane + two k9s panes, one per configured env) via
// tea.ExecProcess.
type fullModel struct { type fullModel struct {
list list.Model list list.Model
state screenState state screenState
@@ -39,51 +48,50 @@ type fullModel struct {
} }
func newFullModel() *fullModel { func newFullModel() *fullModel {
l := list.New(nil, list.NewDefaultDelegate(), 0, 0) l := list.New(nil, newCompactDelegate(), 0, 0)
l.Title = "kctl-tui" l.Title = "kctl-tui"
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
return &fullModel{list: l, state: screenContext} l.SetFilteringEnabled(false)
return &fullModel{list: l}
} }
func (m *fullModel) Init() tea.Cmd { func (m *fullModel) Init() tea.Cmd {
return m.loadContexts return m.bootstrap
} }
func (m *fullModel) loadContexts() tea.Msg { // bootstrap loads the config and applies the default context so the tool
contexts, err := kubeexec.GetContexts() // can jump straight to the team-selection screen.
func (m *fullModel) bootstrap() tea.Msg {
cfgPath, _ := config.DefaultPath()
cfg, err := config.Load(cfgPath)
if err != nil { if err != nil {
return errMsg{err} return errMsg{err}
} }
current := kubeexec.GetCurrentContext() if len(cfg.Contexts) == 0 {
return errMsg{fmt.Errorf("no 'contexts' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
cfgPath, _ := config.DefaultPath()
cfg, _ := config.Load(cfgPath)
items := make([]list.Item, 0, len(contexts))
if current != "" {
items = append(items, simpleItem{label: "(current) " + current, value: current})
} }
for _, c := range contexts { if len(cfg.Envs) == 0 {
if c != current { return errMsg{fmt.Errorf("no 'envs' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
items = append(items, simpleItem{label: c, value: c})
}
} }
return contextsLoadedMsg{items: items, cfg: cfg} if err := kubeexec.CheckTool("kubectl"); err != nil {
return errMsg{err}
}
return bootstrapMsg{cfg: cfg, context: cfg.EffectiveDefaultContext()}
} }
type contextsLoadedMsg struct { type bootstrapMsg struct {
items []list.Item cfg config.Config
cfg config.Config context string
} }
type contextsLoadedMsg struct{ items []list.Item }
type teamsLoadedMsg struct { type teamsLoadedMsg struct {
items []list.Item items []list.Item
namespaces map[string]map[string]string namespaces map[string]map[string]string
} }
type namespacesLoadedMsg struct { type namespacesLoadedMsg struct{ items []list.Item }
items []list.Item
}
type tmuxDoneMsg struct{ err error } type tmuxDoneMsg struct{ err error }
@@ -100,8 +108,12 @@ func (m *fullModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = msg.err m.err = msg.err
return m, nil return m, nil
case contextsLoadedMsg: case bootstrapMsg:
m.cfg = msg.cfg m.cfg = msg.cfg
m.selectedContext = msg.context
return m, m.loadTeams
case contextsLoadedMsg:
m.state = screenContext m.state = screenContext
m.list.Title = "Select context (enter = confirm, esc/ctrl+c = quit)" m.list.Title = "Select context (enter = confirm, esc/ctrl+c = quit)"
m.list.SetItems(msg.items) m.list.SetItems(msg.items)
@@ -110,7 +122,7 @@ func (m *fullModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case teamsLoadedMsg: case teamsLoadedMsg:
m.namespaces = msg.namespaces m.namespaces = msg.namespaces
m.state = screenTeam m.state = screenTeam
m.list.Title = "Select team (esc = back to context)" m.list.Title = fmt.Sprintf("Select team [context=%s] (esc = back to context)", m.selectedContext)
m.list.SetItems(msg.items) m.list.SetItems(msg.items)
return m, nil return m, nil
@@ -157,6 +169,18 @@ func (m *fullModel) handleBack() (tea.Model, tea.Cmd) {
} }
} }
func (m *fullModel) loadContexts() tea.Msg {
items := make([]list.Item, 0, len(m.cfg.Contexts))
for _, c := range m.cfg.Contexts {
label := c
if c == m.selectedContext {
label = "(current) " + c
}
items = append(items, simpleItem{label: label, value: c})
}
return contextsLoadedMsg{items: items}
}
func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) { func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem) item, ok := m.list.SelectedItem().(simpleItem)
if !ok { if !ok {
@@ -166,9 +190,6 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
switch m.state { switch m.state {
case screenContext: case screenContext:
m.selectedContext = item.value m.selectedContext = item.value
if err := kubeexec.UseContext(item.value); err != nil {
return m, func() tea.Msg { return errMsg{err} }
}
return m, m.loadTeams return m, m.loadTeams
case screenTeam: case screenTeam:
@@ -177,8 +198,17 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
case screenNamespace: case screenNamespace:
m.selectedNamespace = item.value m.selectedNamespace = item.value
if err := kubeexec.SetNamespace(item.value); err != nil { tool := "tmux"
return m, func() tea.Msg { return errMsg{err} } if runtime.GOOS == "windows" && m.cfg.MultiplexerBackend() == "wt" {
tool = "wt"
}
if err := kubeexec.CheckTool(tool); 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()
} }
@@ -186,11 +216,20 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
} }
func (m *fullModel) loadTeams() tea.Msg { func (m *fullModel) loadTeams() tea.Msg {
namespaces, err := kubeexec.GetNamespacesWithLabels() merged := map[string]map[string]string{}
if err != nil { for _, env := range m.cfg.Envs {
return errMsg{err} ctx := m.cfg.ResolveContext(env, m.selectedContext)
namespaces, err := kubeexec.GetNamespacesWithLabels(ctx)
if err != nil {
continue
}
for ns, labels := range namespaces {
if _, exists := merged[ns]; !exists {
merged[ns] = labels
}
}
} }
return *toTeamsLoadedMsg(namespaces, m.cfg.TeamLabelKey) return *toTeamsLoadedMsg(merged, m.cfg.TeamLabelKey)
} }
func (m *fullModel) loadTeamsFor(namespaces map[string]map[string]string) tea.Cmd { func (m *fullModel) loadTeamsFor(namespaces map[string]map[string]string) tea.Cmd {
@@ -216,6 +255,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)
} }
@@ -227,34 +267,109 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
} }
} }
// startTmuxSession builds the 3-pane tmux command (control pane running // startTmuxSession builds the 3-pane session: the control pane runs
// this binary in "panel" mode, plus two k9s status panes) and runs it via // this binary in "panel" mode (letting the user pick an env and an
// tea.ExecProcess so the Bubble Tea UI cleanly hands over the terminal. // action), and the two status panes run k9s against the first two
// configured envs, resolved via the context template, so both are
// visible side by side. On Windows with multiplexer: "wt", this uses
// Windows Terminal's native split-pane instead of tmux/psmux.
func (m *fullModel) startTmuxSession() tea.Cmd { func (m *fullModel) startTmuxSession() tea.Cmd {
selfPath := "kctl-tui" // resolved via PATH; see README for install instructions if runtime.GOOS == "windows" && m.cfg.MultiplexerBackend() == "wt" {
panelCmd := fmt.Sprintf("%s panel --ctx=%s --ns=%s --team=%s", return m.startWtSession()
selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam)
k9sCmdA := fmt.Sprintf("k9s --context %s -n %s", m.selectedContext, m.selectedNamespace)
secondCtx := m.selectedContext
if next, ok := findNextContext(m.selectedContext, m.cfg.ContextPairs); ok {
secondCtx = next
} }
k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", secondCtx, m.selectedNamespace) return m.startTmuxSessionTmux()
}
c := exec.Command("tmux", "new-session", "-d", "-s", "kctl", // startTmuxSessionTmux creates the session using tmux/psmux.
panelCmd, ";", func (m *fullModel) startTmuxSessionTmux() tea.Cmd {
"split-window", "-v", k9sCmdA, ";", selfPath, err := os.Executable()
"split-window", "-v", k9sCmdB, ";", if err != nil {
"select-layout", "main-horizontal", ";", selfPath = "kctl-tui" // fallback to PATH lookup
"attach", "-t", "kctl", }
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 --namespace %s --command pods", ctxA, m.selectedNamespace)
kubeexec.VerboseLog("[debug] selfPath=%s\n", selfPath)
kubeexec.VerboseLog("[debug] panelCmd=%s\n", panelCmd)
kubeexec.VerboseLog("[debug] k9sCmdA=%s\n", k9sCmdA)
// Kill stale session first (ignore error if none exists).
exec.Command("tmux", "kill-session", "-t", "kctl").Run()
// Run setup commands individually — this avoids the ; separator
// issue on Windows where psmux doesn't handle chained args.
setup := [][]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 --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}
}) })
} }
// startWtSession creates the session using Windows Terminal's native
// split-pane feature. This avoids the psmux focus-freeze issue on Windows.
// Note: In Windows Terminal, -H (horizontal) stacks panes top/bottom,
// while -V (vertical) places them side by side — opposite of tmux.
// The -s flag controls the split ratio: first split gives k9sA 75%
// (panel keeps 25%), second split divides k9sA equally (37.5% each).
func (m *fullModel) startWtSession() tea.Cmd {
selfPath, err := os.Executable()
if err != nil {
selfPath = "kctl-tui"
}
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 --namespace %s --command pods", ctxA, m.selectedNamespace)
kubeexec.VerboseLog("[debug] selfPath=%s\n", selfPath)
kubeexec.VerboseLog("[debug] panelCmd=%s\n", panelCmd)
kubeexec.VerboseLog("[debug] k9sCmdA=%s\n", k9sCmdA)
wtCmd := fmt.Sprintf("wt -F new-tab %s ; split-pane -H -s 0.75 %s", panelCmd, 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)
wtCmd += fmt.Sprintf(" ; split-pane -H -s 0.5 %s", k9sCmdB)
}
c := exec.Command("cmd", "/c", wtCmd)
_ = c.Start()
return nil
}
func (m *fullModel) View() string { func (m *fullModel) View() string {
view := m.list.View() view := m.list.View()
if m.statusMessage != "" { if m.statusMessage != "" {
+9 -2
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"
@@ -10,6 +13,10 @@ func namespacesForLabelValue(namespaces map[string]map[string]string, labelKey,
return kctl.NamespacesForLabelValue(namespaces, labelKey, value) return kctl.NamespacesForLabelValue(namespaces, labelKey, value)
} }
func findNextContext(current string, pairs []kctl.ContextPair) (string, bool) { func diffSecretValues(left, right map[string]string) []kctl.SecretDiffEntry {
return kctl.FindNextContext(current, pairs) return kctl.DiffSecretValues(left, right)
}
func anyMismatch(entries []kctl.SecretDiffEntry) bool {
return kctl.AnyMismatch(entries)
} }
+15
View File
@@ -1,5 +1,10 @@
// 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/charmbracelet/bubbles/list"
// simpleItem is a minimal implementation of list.Item used for all // simpleItem is a minimal implementation of list.Item used for all
// selection screens (contexts, teams, namespaces, menu actions). // selection screens (contexts, teams, namespaces, menu actions).
type simpleItem struct { type simpleItem struct {
@@ -10,3 +15,13 @@ type simpleItem struct {
func (i simpleItem) Title() string { return i.label } func (i simpleItem) Title() string { return i.label }
func (i simpleItem) Description() string { return "" } func (i simpleItem) Description() string { return "" }
func (i simpleItem) FilterValue() string { return i.label } func (i simpleItem) FilterValue() string { return i.label }
// newCompactDelegate returns a list delegate that renders each item as a
// single line with no extra spacing, maximizing the number of visible
// entries in the terminal.
func newCompactDelegate() list.DefaultDelegate {
d := list.NewDefaultDelegate()
d.ShowDescription = false
d.SetSpacing(0)
return d
}
+322 -4
View File
@@ -1,19 +1,108 @@
// 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"
"os/exec"
"path/filepath"
"runtime"
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
case "update":
if err := runUpdate(verbose); err != nil {
fmt.Fprintln(os.Stderr, "kctl-tui update 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 }
// Load config early to check auto_update_update and validate.
cfgPath, err := config.DefaultPath()
if err == nil {
cfg, cfgErr := config.Load(cfgPath)
if cfgErr != nil {
fmt.Fprintf(os.Stderr, "WARNING: failed to load config: %v\n", cfgErr)
}
if cfgErr == nil && cfg.IsAutoUpdateCheckEnabled() {
updated, err := checkForUpdateInteractive(verbose)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if updated {
os.Exit(0)
}
}
} }
m := newFullModel() m := newFullModel()
@@ -23,3 +112,232 @@ 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 update update to the latest release
kctl-tui config edit open ~/.kctl-tui/config.yaml in editor
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 update # update to the latest version
kctl-tui --verbose 2>debug.log # log commands to a file
kctl-tui config edit # open config in editor
kctl-tui config check # validate config
`)
}
func runConfig(args []string) error {
if len(args) > 0 && args[0] == "check" {
return runConfigCheck()
}
if len(args) == 0 || args[0] == "edit" {
return runConfigEdit()
}
return fmt.Errorf("usage: kctl-tui config [check|edit]")
}
func runConfigEdit() error {
cfgPath, err := config.DefaultPath()
if err != nil {
return fmt.Errorf("cannot determine config path: %w", err)
}
dir := filepath.Dir(cfgPath)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("cannot create config directory %s: %w", dir, err)
}
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
if err := os.WriteFile(cfgPath, []byte("# kctl-tui configuration\n# See https://github.com/skoelle/kctl-tui for examples.\n"), 0o600); err != nil {
return fmt.Errorf("cannot create config file %s: %w", cfgPath, err)
}
fmt.Printf("Created new config file: %s\n", cfgPath)
}
editor := os.Getenv("VISUAL")
if editor == "" {
editor = os.Getenv("EDITOR")
}
if editor == "" {
switch runtime.GOOS {
case "windows":
editor = "notepad"
default:
editor = "vim"
}
}
cmd := exec.Command(editor, cfgPath)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func runConfigCheck() error {
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
}
+345 -165
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,96 +8,151 @@ import (
"flag" "flag"
"fmt" "fmt"
"os/exec" "os/exec"
"runtime"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/skoelle/kctl-tui/internal/config"
"github.com/skoelle/kctl-tui/internal/kctl"
"github.com/skoelle/kctl-tui/internal/kubeexec" "github.com/skoelle/kctl-tui/internal/kubeexec"
) )
// panelStep identifies which part of the redeploy/secrets wizard is shown. // panelStep identifies which part of the env/action wizard is shown.
type panelStep int type panelStep int
const ( const (
stepMenu panelStep = iota stepEnvMenu panelStep = iota
stepActionMenu
stepRedeployList stepRedeployList
stepRedeployConfirm stepRedeployConfirm
stepSecretID stepAWSAuthPrompt
stepSecretRegion
stepSecretKeyList
stepK8sSecretName
stepK8sFieldName
stepDiffResult stepDiffResult
stepForceSyncConfirm stepForceSyncConfirm
stepExternalSecretName stepExternalSecretName
stepDone stepDone
stepError
) )
type panelModel struct { type panelModel struct {
ctx, ns, team string context, ns, team string
cfg config.Config
step panelStep currentEnv string
list list.Model
step panelStep
list list.Model
input textinput.Model input textinput.Model
awsSecretID string deploymentName string
awsRegion string
awsSecretName string // resolved via secret_name_template (namespace + env)
k8sSecretName string // resolved via k8s_secret_name_template (namespace only)
externalSecretName string // resolved via external_secret_name_template (namespace only)
awsValues map[string]string awsValues map[string]string
selectedKey string k8sValues map[string]string
awsValue string diffEntries []kctl.SecretDiffEntry
k8sSecretName string diffOffset int // scroll position for diff table
k8sFieldName string
k8sValue string
message string message string
err error err error
}
// isWtMode returns true when running on Windows with multiplexer: "wt".
func (m *panelModel) isWtMode() bool {
return runtime.GOOS == "windows" && m.cfg.MultiplexerBackend() == "wt"
} }
func runPanel(args []string) error { func runPanel(args []string) error {
fs := flag.NewFlagSet("panel", flag.ContinueOnError) fs := flag.NewFlagSet("panel", flag.ContinueOnError)
ctx := fs.String("ctx", "", "kubectl context") context := fs.String("context", "", "context (e.g. internal/external)")
ns := fs.String("ns", "", "namespace") ns := fs.String("ns", "", "namespace")
team := fs.String("team", "", "team label value") team := fs.String("team", "", "team label value")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
m := newPanelModel(*ctx, *ns, *team) m := newPanelModel(*context, *ns, *team)
p := tea.NewProgram(m, tea.WithAltScreen()) p := tea.NewProgram(m, tea.WithAltScreen())
_, err := p.Run() _, err := p.Run()
return err return err
} }
func newPanelModel(ctx, ns, team string) *panelModel { func newPanelModel(context, ns, team string) *panelModel {
l := list.New(menuItems(), list.NewDefaultDelegate(), 0, 0)
l.Title = fmt.Sprintf("kctl-tui panel [ctx=%s ns=%s team=%s]", ctx, ns, team)
l.SetShowStatusBar(false)
ti := textinput.New() ti := textinput.New()
ti.Focus() ti.Focus()
return &panelModel{ctx: ctx, ns: ns, team: team, step: stepMenu, list: l, input: ti} cfgPath, _ := config.DefaultPath()
cfg, loadErr := config.Load(cfgPath)
l := list.New(nil, newCompactDelegate(), 0, 0)
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
m := &panelModel{context: context, ns: ns, team: team, cfg: cfg, step: stepEnvMenu, list: l, input: ti}
if m.isWtMode() {
l.KeyMap.Quit.SetEnabled(false) // "q" deaktivieren, "ctrl+c" bleibt
}
if loadErr != nil {
m.err = fmt.Errorf("config load failed: %w", loadErr)
m.step = stepError
} else {
m.showEnvMenu()
}
return m
} }
func menuItems() []list.Item { func (m *panelModel) showEnvMenu() {
return []list.Item{ items := make([]list.Item, 0, len(m.cfg.Envs)+1)
simpleItem{label: "Redeploy (rollout restart)", value: "redeploy"}, if !m.isWtMode() {
simpleItem{label: "Secrets: AWS <-> Kubernetes diff", value: "secrets"}, items = append(items, simpleItem{label: "Quit (closes this tmux session)", value: "quit"})
simpleItem{label: "Quit (closes this tmux session)", value: "quit"},
} }
for _, env := range m.cfg.Envs {
items = append(items, simpleItem{label: env, value: env})
}
m.list.SetItems(items)
m.list.Title = fmt.Sprintf("kctl-tui panel [context=%s ns=%s team=%s]", m.context, m.ns, m.team)
m.step = stepEnvMenu
m.currentEnv = ""
m.message = ""
m.err = nil
}
func (m *panelModel) showActionMenu() {
m.list.SetItems([]list.Item{
simpleItem{label: "Secrets sync (AWS <-> Kubernetes)", value: "secrets"},
simpleItem{label: "Redeploy (rollout restart)", value: "redeploy"},
})
m.list.Title = fmt.Sprintf("kctl-tui panel [context=%s ns=%s team=%s env=%s] (esc = back)",
m.context, m.ns, m.team, m.currentEnv)
m.step = stepActionMenu
m.message = ""
m.err = nil
}
// resolvedContext returns the actual kubectl context/ARN for the
// currently selected env, built from the configured context_template.
func (m *panelModel) resolvedContext() string {
return m.cfg.ResolveContext(m.currentEnv, m.context)
} }
func (m *panelModel) Init() tea.Cmd { return nil } func (m *panelModel) Init() tea.Cmd { return nil }
type awsLoginDoneMsg struct{ err error }
func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height-2) m.list.SetSize(msg.Width, msg.Height-2)
return m, nil return m, nil
case awsLoginDoneMsg:
return m.afterAWSLogin(msg.err)
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
@@ -103,6 +161,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)
}
} }
} }
@@ -118,103 +184,185 @@ func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m *panelModel) usesTextInput() bool { func (m *panelModel) usesTextInput() bool {
switch m.step { return m.step == stepExternalSecretName
case stepSecretID, stepSecretRegion, stepK8sSecretName, stepK8sFieldName, stepExternalSecretName:
return true
}
return false
} }
// handleEsc closes the whole tmux session (all panes, including the two // handleEsc navigates one level up: action menu -> env menu, most
// k9s status panes) before quitting this program, per SPEC.md 3.6. // sub-steps -> action menu. From the top-level env menu it closes the
// whole tmux session (all panes, including the two k9s status panes)
// before quitting this program, per SPEC.md 3.6. In wt mode, Esc on
// the env menu does nothing — the user closes the wt window manually.
func (m *panelModel) handleEsc() (tea.Model, tea.Cmd) { func (m *panelModel) handleEsc() (tea.Model, tea.Cmd) {
exec.Command("tmux", "kill-session", "-t", "kctl").Run() switch m.step {
return m, tea.Quit case stepEnvMenu:
if m.isWtMode() {
return m, nil
}
exec.Command("tmux", "kill-session", "-t", "kctl").Run()
return m, tea.Quit
case stepActionMenu:
m.showEnvMenu()
return m, nil
default:
m.showActionMenu()
return m, nil
}
} }
func (m *panelModel) handleEnter() (tea.Model, tea.Cmd) { func (m *panelModel) handleEnter() (tea.Model, tea.Cmd) {
switch m.step { switch m.step {
case stepMenu: case stepEnvMenu:
return m.fromMenu() return m.fromEnvMenu()
case stepActionMenu:
return m.fromActionMenu()
case stepRedeployList: case stepRedeployList:
return m.fromRedeployList() return m.fromRedeployList()
case stepRedeployConfirm: case stepRedeployConfirm:
return m.fromRedeployConfirm() return m.fromRedeployConfirm()
case stepSecretID: case stepAWSAuthPrompt:
m.awsSecretID = m.input.Value() return m.fromAWSAuthPrompt()
m.step = stepSecretRegion
m.input.SetValue("eu-central-1")
return m, nil
case stepSecretRegion:
m.awsRegion = m.input.Value()
return m.fetchAWSSecret()
case stepSecretKeyList:
return m.fromSecretKeyList()
case stepK8sSecretName:
m.k8sSecretName = m.input.Value()
m.step = stepK8sFieldName
m.input.SetValue("")
return m, nil
case stepK8sFieldName:
m.k8sFieldName = m.input.Value()
return m.compareSecret()
case stepForceSyncConfirm: case stepForceSyncConfirm:
return m.fromForceSyncConfirm() return m.fromForceSyncConfirm()
case stepExternalSecretName: case stepExternalSecretName:
return m.doForceSync() return m.doForceSync()
case stepDiffResult, stepDone: case stepDiffResult, stepDone, stepError:
m.resetToMenu() m.showActionMenu()
return m, nil return m, nil
} }
return m, nil return m, nil
} }
func (m *panelModel) resetToMenu() { // showError switches to a dedicated error screen so failures from
m.step = stepMenu // kubectl/aws calls stay visible until the user explicitly acknowledges
m.list.SetItems(menuItems()) // them with Enter, instead of being silently discarded.
m.list.Title = "kctl-tui panel" func (m *panelModel) showError(err error) (tea.Model, tea.Cmd) {
m.err = err
m.step = stepError
return m, nil
} }
func (m *panelModel) fromMenu() (tea.Model, tea.Cmd) { func (m *panelModel) fromEnvMenu() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
if item.value == "quit" {
return m.handleEsc()
}
m.currentEnv = item.value
m.showActionMenu()
return m, nil
}
func (m *panelModel) fromActionMenu() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem) item, ok := m.list.SelectedItem().(simpleItem)
if !ok { if !ok {
return m, nil return m, nil
} }
switch item.value { switch item.value {
case "redeploy": case "redeploy":
deployments, err := kubeexec.GetDeployments(m.ns) deployments, err := kubeexec.GetDeployments(m.resolvedContext(), m.ns)
if err != nil { if err != nil {
m.err = err return m.showError(err)
return m, nil
} }
items := make([]list.Item, 0, len(deployments)) items := make([]list.Item, 0, len(deployments))
for _, d := range deployments { for _, d := range deployments {
items = append(items, simpleItem{label: d, value: d}) items = append(items, simpleItem{label: d, value: d})
} }
m.list.SetItems(items) m.list.SetItems(items)
m.list.Title = "Select deployment to restart (esc = back)" m.list.Title = fmt.Sprintf("Select deployment to restart [env=%s] (esc = back)", m.currentEnv)
m.step = stepRedeployList m.step = stepRedeployList
case "secrets": case "secrets":
m.step = stepSecretID return m.checkAWSAuthAndProceed()
m.input.SetValue("")
m.input.Placeholder = "AWS secret ID"
case "quit":
return m.handleEsc()
} }
return m, nil return m, nil
} }
// checkAWSAuthAndProceed verifies the current AWS credentials/SSO session
// before entering the secrets workflow. If the check fails (e.g. an
// expired SSO session), it offers to run the configured login command
// interactively instead of letting the user hit a confusing failure
// several steps later.
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 {
m.err = err
m.list.SetItems([]list.Item{
simpleItem{label: "Run AWS login now (" + m.cfg.LoginCommand() + ")", value: "login"},
simpleItem{label: "Cancel", value: "cancel"},
})
m.list.Title = "AWS session invalid or expired"
m.step = stepAWSAuthPrompt
return m, nil
}
return m.startSecretsFlow()
}
func (m *panelModel) fromAWSAuthPrompt() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok || item.value != "login" {
m.showActionMenu()
return m, nil
}
cmd := kubeexec.RunAWSLogin(m.cfg.LoginCommand())
return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
return awsLoginDoneMsg{err: err}
})
}
// afterAWSLogin re-checks AWS auth once the interactive login command has
// finished (successfully or not) and either proceeds into the secrets
// workflow or shows the remaining error.
func (m *panelModel) afterAWSLogin(execErr error) (tea.Model, tea.Cmd) {
if execErr != nil {
return m.showError(fmt.Errorf("login command failed to run: %w", execErr))
}
if err := kubeexec.CheckAWSAuth(); err != nil {
return m.showError(fmt.Errorf("still not authenticated with AWS after running '%s': %w", m.cfg.LoginCommand(), err))
}
return m.startSecretsFlow()
}
// startSecretsFlow computes the AWS secret ID (namespace + env) and the
// Kubernetes secret name (namespace only) from their respective
// templates and fetches the AWS side directly - no manual input required
// for either name.
func (m *panelModel) startSecretsFlow() (tea.Model, tea.Cmd) {
m.awsSecretName = m.cfg.ResolveSecretName(m.ns, m.currentEnv)
m.k8sSecretName = m.cfg.ResolveK8sSecretName(m.ns)
m.externalSecretName = m.cfg.ResolveExternalSecretName(m.ns)
raw, err := kubeexec.GetAWSSecretString(m.awsSecretName, m.cfg.AWSRegion)
if err != nil {
return m.showError(fmt.Errorf("failed to fetch AWS secret %q: %w", m.awsSecretName, err))
}
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
// Not a JSON secret - treat the whole value as a single field.
m.awsValues = map[string]string{"value": raw}
} else {
m.awsValues = map[string]string{}
for k, v := range parsed {
m.awsValues[k] = fmt.Sprintf("%v", v)
}
}
return m.compareAllFields()
}
func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) { func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem) item, ok := m.list.SelectedItem().(simpleItem)
if !ok { if !ok {
return m, nil return m, nil
} }
m.selectedKey = item.value // reused as "deployment name" here m.deploymentName = item.value
m.list.SetItems([]list.Item{ m.list.SetItems([]list.Item{
simpleItem{label: "Yes, restart " + item.value, value: "yes"}, simpleItem{label: "Yes, restart " + item.value, value: "yes"},
simpleItem{label: "Cancel", value: "no"}, simpleItem{label: "Cancel", value: "no"},
}) })
m.list.Title = "Confirm rollout restart" m.list.Title = fmt.Sprintf("Confirm rollout restart [env=%s]", m.currentEnv)
m.step = stepRedeployConfirm m.step = stepRedeployConfirm
return m, nil return m, nil
} }
@@ -222,89 +370,108 @@ func (m *panelModel) fromRedeployList() (tea.Model, tea.Cmd) {
func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) { func (m *panelModel) fromRedeployConfirm() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem) item, ok := m.list.SelectedItem().(simpleItem)
if !ok || item.value != "yes" { if !ok || item.value != "yes" {
m.resetToMenu() m.showActionMenu()
return m, nil return m, nil
} }
_, err := kubeexec.RolloutRestart(m.ns, m.selectedKey) ctx := m.resolvedContext()
_, err := kubeexec.RolloutRestart(ctx, m.ns, m.deploymentName)
if err != nil { if err != nil {
m.err = err return m.showError(err)
} }
status, _ := kubeexec.RolloutStatus(m.ns, m.selectedKey) status, err := kubeexec.RolloutStatus(ctx, m.ns, m.deploymentName)
m.message = "Rollout status: " + status if err != nil {
return m.showError(err)
}
m.message = fmt.Sprintf("[env=%s] Rollout status: %s", m.currentEnv, status)
m.step = stepDone m.step = stepDone
return m, nil return m, nil
} }
func (m *panelModel) fetchAWSSecret() (tea.Model, tea.Cmd) { // compareAllFields fetches every field of the Kubernetes secret and diffs
raw, err := kubeexec.GetAWSSecretString(m.awsSecretID, m.awsRegion) // it against every key of the AWS secret in one go.
func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
k8sValues, err := kubeexec.GetSecretAllFields(m.resolvedContext(), m.ns, m.k8sSecretName)
if err != nil { if err != nil {
m.err = err return m.showError(fmt.Errorf("failed to fetch Kubernetes secret %q: %w", m.k8sSecretName, err))
m.resetToMenu()
return m, nil
} }
var parsed map[string]interface{} m.k8sValues = k8sValues
if err := json.Unmarshal([]byte(raw), &parsed); err != nil { m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
m.awsValues = map[string]string{"__raw__": raw} m.diffOffset = 0
m.message = renderDiffTable(m.currentEnv, m.awsSecretName, m.k8sSecretName, m.diffEntries, m.diffOffset, 0)
if anyMismatch(m.diffEntries) {
m.list.SetItems([]list.Item{
simpleItem{label: "Yes, request force-sync for this secret", value: "yes"},
simpleItem{label: "No", value: "no"},
})
m.list.Title = "Values differ - request ExternalSecret force-sync?"
m.step = stepForceSyncConfirm
} else { } else {
m.awsValues = map[string]string{}
for k, v := range parsed {
m.awsValues[k] = fmt.Sprintf("%v", v)
}
}
items := make([]list.Item, 0, len(m.awsValues))
for k := range m.awsValues {
items = append(items, simpleItem{label: k, value: k})
}
m.list.SetItems(items)
m.list.Title = "Select AWS secret key to compare"
m.step = stepSecretKeyList
return m, nil
}
func (m *panelModel) fromSecretKeyList() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
m.selectedKey = item.value
m.awsValue = m.awsValues[item.value]
m.step = stepK8sSecretName
m.input.SetValue("")
m.input.Placeholder = "Kubernetes secret name"
return m, nil
}
func (m *panelModel) compareSecret() (tea.Model, tea.Cmd) {
b64, err := kubeexec.GetSecretValueBase64(m.ns, m.k8sSecretName, m.k8sFieldName)
if err != nil {
m.err = err
m.resetToMenu()
return m, nil
}
decoded, err := kubeexec.DecodeBase64(b64)
if err != nil {
m.err = err
m.resetToMenu()
return m, nil
}
m.k8sValue = decoded
if m.awsValue == m.k8sValue {
m.message = "IDENTICAL\nAWS: " + m.awsValue + "\nK8s: " + m.k8sValue
m.step = stepDiffResult m.step = stepDiffResult
return m, nil
} }
m.message = "DIFFERENT\nAWS: " + m.awsValue + "\nK8s: " + m.k8sValue
m.list.SetItems([]list.Item{
simpleItem{label: "Yes, request force-sync", value: "yes"},
simpleItem{label: "No", value: "no"},
})
m.list.Title = "Values differ - request ExternalSecret force-sync?"
m.step = stepForceSyncConfirm
return m, nil return m, nil
} }
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
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")
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"
if !e.Match {
status = "MISMATCH"
}
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()
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
if max <= 3 {
return s[:max]
}
return s[:max-3] + "..."
}
func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) { func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(simpleItem) item, ok := m.list.SelectedItem().(simpleItem)
if !ok || item.value != "yes" { if !ok || item.value != "yes" {
@@ -312,50 +479,63 @@ func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.step = stepExternalSecretName m.step = stepExternalSecretName
m.input.SetValue("") m.input.SetValue(m.externalSecretName)
m.input.Placeholder = "ExternalSecret object name" m.input.Placeholder = "ExternalSecret object name"
return m, nil return m, nil
} }
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())
ts := time.Now().Unix() if name == "" {
_, err := kubeexec.AnnotateForceSync(m.ns, name, ts) return m.showError(fmt.Errorf("ExternalSecret name must not be empty"))
if err != nil {
m.err = err
} }
m.message = "Force-sync requested (timestamp " + strconv.FormatInt(ts, 10) + ")." 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()
_, err := kubeexec.AnnotateForceSync(m.resolvedContext(), m.ns, name, ts)
if err != nil {
return m.showError(err)
}
m.message += fmt.Sprintf("\nForce-sync requested for %s (timestamp %s).", name, strconv.FormatInt(ts, 10))
m.step = stepDone m.step = stepDone
return m, nil return m, nil
} }
func (m *panelModel) View() string { func (m *panelModel) View() string {
switch m.step { switch m.step {
case stepMenu, stepRedeployList, stepRedeployConfirm, stepSecretKeyList, stepForceSyncConfirm: case stepEnvMenu, stepActionMenu, stepRedeployList, stepRedeployConfirm:
v := m.list.View() v := m.list.View()
if m.err != nil { if m.err != nil {
v += "\nerror: " + m.err.Error() v += "\nerror: " + m.err.Error()
} }
return v return v
case stepAWSAuthPrompt:
errText := ""
if m.err != nil {
errText = m.err.Error() + "\n\n"
}
return errText + m.list.View()
case stepForceSyncConfirm:
return m.message + "\n\n" + m.list.View()
case stepDiffResult, stepDone: case stepDiffResult, stepDone:
return m.message + "\n\n(press enter to return to menu, esc to close session)" return m.message + "\n\n(press enter to return to the action menu, esc to go back)"
case stepError:
errText := "unknown error"
if m.err != nil {
errText = m.err.Error()
}
return "ERROR:\n\n" + errText + "\n\n(press enter to return to the action menu, esc to go back)"
default: default:
return fmt.Sprintf("%s\n\n%s\n\n(enter = confirm, esc = back to menu/close session)", return fmt.Sprintf("%s\n\n%s\n\n(enter = confirm, esc = back)",
m.stepPrompt(), m.input.View()) m.stepPrompt(), m.input.View())
} }
} }
func (m *panelModel) stepPrompt() string { func (m *panelModel) stepPrompt() string {
switch m.step { if m.step == stepExternalSecretName {
case stepSecretID:
return "AWS Secrets Manager: enter secret ID"
case stepSecretRegion:
return "AWS region"
case stepK8sSecretName:
return "Kubernetes secret name (in namespace " + m.ns + ")"
case stepK8sFieldName:
return "Field name inside the Kubernetes secret for key \"" + m.selectedKey + "\""
case stepExternalSecretName:
return "ExternalSecret object name to annotate" return "ExternalSecret object name to annotate"
} }
return "" return ""
+155
View File
@@ -0,0 +1,155 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package main
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"time"
"github.com/Masterminds/semver/v3"
"github.com/creativeprojects/go-selfupdate"
"golang.org/x/term"
)
const (
githubSlug = "skoelle/kctl-tui"
updateTimeout = 10 * time.Second
)
func initUpdater(verbose bool) (*selfupdate.Updater, error) {
if verbose {
selfupdate.SetLogger(&verboseLogger{})
}
source, err := selfupdate.NewGitHubSource(selfupdate.GitHubConfig{})
if err != nil {
return nil, fmt.Errorf("failed to init GitHub source: %w", err)
}
return selfupdate.NewUpdater(selfupdate.Config{
Source: source,
})
}
func runUpdate(verbose bool) error {
if version == "dev" {
fmt.Fprintln(os.Stderr, "WARNING: running dev build — cannot compare versions")
fmt.Fprintln(os.Stderr, "Skipping version check. Build from a tagged release to enable self-update.")
return nil
}
updater, err := initUpdater(verbose)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), updateTimeout)
defer cancel()
repo := selfupdate.ParseSlug(githubSlug)
rel, found, err := updater.DetectLatest(ctx, repo)
if err != nil {
return fmt.Errorf("failed to check for updates: %w", err)
}
if !found {
fmt.Println("Already up-to-date.")
return nil
}
current, _ := semver.NewVersion(version)
newVersion := rel.Version()
newVer, _ := semver.NewVersion(newVersion)
if current != nil && !current.LessThan(newVer) {
fmt.Println("Already up-to-date.")
return nil
}
fmt.Printf("Current version: %s\n", version)
fmt.Printf("Found version %s. Updating...\n", newVersion)
if err := updater.UpdateTo(ctx, rel, ""); err != nil {
return fmt.Errorf("update failed: %w", err)
}
fmt.Printf("Updated from %s to %s\n", current, newVersion)
return nil
}
// checkForUpdateInteractive checks for a new version and prompts the user to update.
// Returns (true, nil) if an update was applied successfully, (false, nil) if no
// update was needed or the user declined, and (false, err) if the update failed.
func checkForUpdateInteractive(verbose bool) (bool, error) {
if version == "dev" {
return false, nil
}
if !term.IsTerminal(int(os.Stdin.Fd())) {
return false, nil
}
updater, err := initUpdater(verbose)
if err != nil {
if verbose {
fmt.Fprintf(os.Stderr, "Update check failed: %v\n", err)
}
return false, nil
}
ctx, cancel := context.WithTimeout(context.Background(), updateTimeout)
defer cancel()
repo := selfupdate.ParseSlug(githubSlug)
rel, found, err := updater.DetectLatest(ctx, repo)
if err != nil {
if verbose {
fmt.Fprintf(os.Stderr, "Update check failed: %v\n", err)
}
return false, nil
}
if !found {
return false, nil
}
current, _ := semver.NewVersion(version)
newVersion := rel.Version()
newVer, _ := semver.NewVersion(newVersion)
if current != nil && !current.LessThan(newVer) {
return false, nil
}
fmt.Printf("New version %s available (current: %s). Update now? [y/N] ", newVersion, version)
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
return false, nil
}
fmt.Println("Updating...")
if err := updater.UpdateTo(ctx, rel, ""); err != nil {
return false, fmt.Errorf("update failed: %w", err)
}
fmt.Printf("Updated to %s. Please restart kctl-tui.\n", newVersion)
return true, nil
}
type verboseLogger struct{}
func (l *verboseLogger) Print(v ...any) {
fmt.Fprint(os.Stderr, v...)
}
func (l *verboseLogger) Printf(format string, v ...any) {
fmt.Fprintf(os.Stderr, format, v...)
}
+74 -14
View File
@@ -1,19 +1,79 @@
# Example configuration for kctl-tui. # Example configuration for kctl-tui.
# Copy this file to ~/.kctl-tui/config.yaml and adjust the values to your # Copy this file to ~/.kctl-tui/config.yaml and adjust the values to your
# own cluster setup. Do NOT commit your real config.yaml with company- or # own AWS/Kubernetes setup. Do NOT commit your real config.yaml with
# project-specific context/namespace/label names to a public repository. # company- or project-specific account IDs, contexts, or label names to a
# public repository.
# Groups of kubectl contexts that belong together (e.g. the same # Top-level grouping the tool starts from (e.g. network boundary such as
# environment pair, such as staging/production of the same cluster). # internal/external-facing clusters). This is the outermost navigation
# Pressing TAB in the control pane cycles through the contexts listed here # level; press Esc from the team-selection screen to get here.
# while keeping the current namespace. contexts:
context_pairs: - "internal"
- name: "example-environment-pair" - "external"
contexts:
- "example-context-a"
- "example-context-b"
# The namespace label key used to group namespaces by team/ownership in the # Pre-selected on startup so the tool can jump straight to team selection
# team-selection screen. Adjust this to whatever label your organization # instead of asking for the context every time. Falls back to the first
# actually uses (can contain a domain prefix, e.g. "example.org/team"). # entry of 'contexts' if omitted.
default_context: "internal"
# Environments switchable from the control panel (e.g. "1) beta" /
# "2) prod"). The first two entries are also used for the two k9s status
# panes shown side by side.
envs:
- "beta"
- "prod"
# AWS region used for all AWS Secrets Manager calls.
aws_region: "eu-central-1"
# AWS account ID, used to fill the {account_id} placeholder below.
# 123456789012 is a placeholder, not a real account.
aws_account_id: "123456789012"
# Builds the AWS Secrets Manager secret ID from the chosen namespace and
# environment. Available placeholders: {namespace}, {env}.
secret_name_template: "tf-{namespace}-{env}-secrets"
# Builds the Kubernetes secret name from the chosen namespace. Kept as a
# separate template from secret_name_template above because the AWS side
# and the Kubernetes side commonly follow different naming conventions
# (e.g. the Kubernetes secret is per-namespace only, without an env
# segment, because each environment already has its own cluster).
# Available placeholders: {namespace}.
k8s_secret_name_template: "{namespace}-common-secrets"
# Builds the ExternalSecret CRD object name to annotate when a force-sync
# is requested. This is often different from the Kubernetes secret name
# because the ExternalSecret CRD and the resulting Secret are separate
# objects (e.g. ExternalSecret "job-apply" produces Secret
# "job-apply-common-secrets"). Falls back to k8s_secret_name_template
# if omitted. Available placeholders: {namespace}.
external_secret_name_template: "{namespace}"
# Builds the actual kubectl context name/ARN from region, account ID, env,
# and context. Available placeholders: {region}, {account_id}, {env},
# {context}. Adjust the literal parts ("tf-", "-1", cluster naming, ARN
# shape) to match how your own EKS clusters/contexts are actually named.
context_template: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
# The namespace label key used to group namespaces by team/ownership in
# the team-selection screen. Adjust this to whatever label your
# organization actually uses (can contain a domain prefix, e.g.
# "example.org/team").
team_label_key: "example.org/team" team_label_key: "example.org/team"
# Command used to (re-)authenticate with AWS before the Secrets workflow,
# if 'aws sts get-caller-identity' fails (e.g. an expired AWS SSO session).
# Defaults to "aws sso login" if omitted. Override this if your organization
# wraps SSO login in a custom script or needs a specific --profile, e.g.:
# aws_sso_login_command: "aws sso login --profile my-profile"
aws_sso_login_command: "aws sso login"
# Check for updates on startup and prompt to update if a newer version is
# available. Set to false to disable. Defaults to true if omitted.
# auto_update_check: true
# Terminal multiplexer backend. "tmux" (default) uses tmux/psmux.
# "wt" uses Windows Terminal's native split-pane — avoids the psmux
# focus-freeze issue on Windows. Only effective on Windows.
# multiplexer: "tmux"
+46 -4
View File
@@ -1,10 +1,52 @@
module github.com/skoelle/kctl-tui module github.com/skoelle/kctl-tui
go 1.22 go 1.25.12
require ( require (
github.com/charmbracelet/bubbles v0.20.0 github.com/Masterminds/semver/v3 v3.5.0
github.com/charmbracelet/bubbletea v1.1.1 github.com/charmbracelet/bubbles v0.21.1
github.com/charmbracelet/lipgloss v1.0.0 github.com/charmbracelet/bubbletea v1.3.10
github.com/creativeprojects/go-selfupdate v1.6.0
golang.org/x/term v0.44.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require (
code.gitea.io/sdk/gitea v0.23.2 // indirect
github.com/42wim/httpsig v1.2.4 // indirect
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/davidmz/go-pageant v1.0.2 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/google/go-github/v86 v86.0.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-version v1.9.0 // 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/ulikunitz/xz v0.5.15 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
gitlab.com/gitlab-org/api/client-go v1.46.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
+127
View File
@@ -0,0 +1,127 @@
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
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.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.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.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/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
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/creativeprojects/go-selfupdate v1.6.0 h1:Bu3cIgdyfI1Pg8XsL8nbaT2uMjfZ8HIoxnBmPJbN0sw=
github.com/creativeprojects/go-selfupdate v1.6.0/go.mod h1:Ids8O474XGQG0jZ5vpBIhWffcGYjUP6ccOI0mMcvQbI=
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/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
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/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v86 v86.0.0 h1:S/6aANJhwRm8EQmGKVML3j41yq0h2BsTP8FnDkO7kcA=
github.com/google/go-github/v86 v86.0.0/go.mod h1:zKv1l4SwDXNFMGByi2FWkq71KwSXqj/eQRZuqtmcot8=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/graph-gophers/graphql-go v1.9.0 h1:yu0ucKHLc5qGpRwLYKIWtr9bOoxovkWasuBrPQwlHls=
github.com/graph-gophers/graphql-go v1.9.0/go.mod h1:23olKZ7duEvHlF/2ELEoSZaY1aNPfShjP782SOoNTyM=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
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.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
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.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.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
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/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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
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=
gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24=
gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
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."
+42 -4
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).
@@ -37,13 +39,50 @@ case "$os" in
esac esac
echo "Detecting latest release for $REPO ..." echo "Detecting latest release for $REPO ..."
latest_tag="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep -m1 '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')"
if [ -z "$latest_tag" ]; then release_json="$(curl -sSL "https://api.github.com/repos/${REPO}/releases/latest")" || {
echo "Could not determine latest release tag. Is there at least one published release?" >&2 echo "Failed to reach the GitHub API (network error). Check your internet connection and try again." >&2
exit 1
}
echo "Received ${#release_json} bytes from the GitHub API."
if echo "$release_json" | grep -q '"message"[[:space:]]*:[[:space:]]*"Not Found"'; then
echo "" >&2
echo "Could not find a published release for ${REPO}." >&2
echo "This usually means no release has been tagged yet." >&2
echo "" >&2
echo "Options:" >&2
echo " 1) Ask the maintainer to push a version tag (e.g. 'git tag v0.1.0 && git push origin v0.1.0')," >&2
echo " which triggers the release build via GitHub Actions." >&2
echo " 2) Build from source instead:" >&2
echo " git clone https://github.com/${REPO}.git" >&2
echo " cd $(basename "$REPO")" >&2
echo " go build -o ${BIN_NAME} ./cmd/${BIN_NAME}" >&2
echo " sudo mv ${BIN_NAME} ${INSTALL_DIR}/" >&2
exit 1 exit 1
fi fi
if echo "$release_json" | grep -qi 'rate limit exceeded'; then
echo "GitHub API rate limit exceeded. Wait a bit and try again, or authenticate with a GitHub token." >&2
exit 1
fi
# Note: '|| true' below prevents 'set -o pipefail' + 'set -e' from aborting the
# script silently if grep finds no match; the emptiness check right after
# gives a proper diagnostic instead.
latest_tag="$(echo "$release_json" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')" || true
if [ -z "$latest_tag" ]; then
echo "Could not parse the latest release tag from the GitHub API response." >&2
echo "Raw response (truncated):" >&2
echo "$release_json" | head -c 800 >&2
echo "" >&2
exit 1
fi
echo "Latest release tag: ${latest_tag}"
asset="kctl-tui-${os}-${arch}" asset="kctl-tui-${os}-${arch}"
url="https://github.com/${REPO}/releases/download/${latest_tag}/${asset}" url="https://github.com/${REPO}/releases/download/${latest_tag}/${asset}"
@@ -60,5 +99,4 @@ else
fi fi
echo "Installed ${BIN_NAME} to ${INSTALL_DIR}/${BIN_NAME}" echo "Installed ${BIN_NAME} to ${INSTALL_DIR}/${BIN_NAME}"
"${INSTALL_DIR}/${BIN_NAME}" --help >/dev/null 2>&1 || true
echo "Done. Run '${BIN_NAME}' to get started." echo "Done. Run '${BIN_NAME}' to get started."
+162 -6
View File
@@ -1,5 +1,8 @@
// 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 (context pairs, team label key) from a YAML file. // configuration (contexts, envs, templates) from a YAML file.
package config package config
import ( import (
@@ -11,10 +14,162 @@ import (
"github.com/skoelle/kctl-tui/internal/kctl" "github.com/skoelle/kctl-tui/internal/kctl"
) )
// DefaultAWSSSOLoginCommand is used when the user has not configured a
// custom login command in their config.yaml.
const DefaultAWSSSOLoginCommand = "aws sso login"
// Config is the root structure of ~/.kctl-tui/config.yaml // Config is the root structure of ~/.kctl-tui/config.yaml
type Config struct { type Config struct {
ContextPairs []kctl.ContextPair `yaml:"context_pairs"` // Contexts are the top-level groupings the tool starts from, e.g.
TeamLabelKey string `yaml:"team_label_key"` // "internal"/"external". This is the outermost navigation level.
Contexts []string `yaml:"contexts"`
// DefaultContext is pre-selected on startup so the tool can jump
// straight to team selection; falls back to the first entry of
// Contexts if empty.
DefaultContext string `yaml:"default_context"`
// Envs are the environments switchable from the control panel, e.g.
// "beta"/"prod". The first two entries are used for the two k9s
// status panes.
Envs []string `yaml:"envs"`
// AWSRegion is used for all AWS Secrets Manager calls.
AWSRegion string `yaml:"aws_region"`
// AWSAccountID fills the {account_id} placeholder in ContextTemplate.
AWSAccountID string `yaml:"aws_account_id"`
// SecretNameTemplate builds the AWS Secrets Manager secret ID from a
// namespace and env, e.g. "tf-{namespace}-{env}-secrets".
SecretNameTemplate string `yaml:"secret_name_template"`
// K8sSecretNameTemplate builds the Kubernetes secret name from a
// namespace, e.g. "{namespace}-common-secrets". Kept separate from
// SecretNameTemplate because the two sides commonly follow different
// naming conventions.
K8sSecretNameTemplate string `yaml:"k8s_secret_name_template"`
// ExternalSecretNameTemplate builds the ExternalSecret CRD object
// name that should be annotated when a force-sync is requested. This
// is often different from the Kubernetes secret name because the
// ExternalSecret CRD and the resulting Secret are separate objects.
// Falls back to K8sSecretNameTemplate if empty.
ExternalSecretNameTemplate string `yaml:"external_secret_name_template"`
// ContextTemplate builds the actual kubectl context name/ARN from
// region, account_id, env, and context, e.g.
// "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1".
ContextTemplate string `yaml:"context_template"`
// TeamLabelKey is the namespace label used to group namespaces by
// team/ownership in the team-selection screen.
TeamLabelKey string `yaml:"team_label_key"`
// AWSSSOLoginCommand is run interactively if an AWS auth check fails
// before the secrets workflow (e.g. an expired SSO session).
AWSSSOLoginCommand string `yaml:"aws_sso_login_command"`
// AutoUpdateCheck controls whether kctl-tui checks for updates on
// startup. Defaults to true when omitted.
AutoUpdateCheck *bool `yaml:"auto_update_check"`
// Multiplexer selects the terminal multiplexer for the session.
// "tmux" (default) uses tmux/psmux. "wt" uses Windows Terminal's
// native split-pane (only effective on Windows).
Multiplexer string `yaml:"multiplexer"`
}
// IsAutoUpdateCheckEnabled returns true unless the user has explicitly set
// auto_update_check to false in their config.
func (c Config) IsAutoUpdateCheckEnabled() bool {
if c.AutoUpdateCheck == nil {
return true
}
return *c.AutoUpdateCheck
}
// MultiplexerBackend returns the configured multiplexer backend,
// falling back to "tmux" if not set.
func (c Config) MultiplexerBackend() string {
if c.Multiplexer == "" {
return "tmux"
}
return c.Multiplexer
}
// LoginCommand returns the configured AWS SSO login command, falling back
// to DefaultAWSSSOLoginCommand if none is set.
func (c Config) LoginCommand() string {
if c.AWSSSOLoginCommand == "" {
return DefaultAWSSSOLoginCommand
}
return c.AWSSSOLoginCommand
}
// EffectiveDefaultContext returns DefaultContext if set, otherwise the
// first entry of Contexts, otherwise an empty string.
func (c Config) EffectiveDefaultContext() string {
if c.DefaultContext != "" {
return c.DefaultContext
}
if len(c.Contexts) > 0 {
return c.Contexts[0]
}
return ""
}
// ResolveContext builds the actual kubectl context name/ARN for a given
// env + context (e.g. "beta" + "internal") using ContextTemplate.
func (c Config) ResolveContext(env, context string) string {
return kctl.ResolveTemplate(c.ContextTemplate, map[string]string{
"region": c.AWSRegion,
"account_id": c.AWSAccountID,
"env": env,
"context": context,
})
}
// ResolveSecretName builds the AWS Secrets Manager secret ID for a given
// namespace + env using SecretNameTemplate.
func (c Config) ResolveSecretName(namespace, env string) string {
return kctl.ResolveTemplate(c.SecretNameTemplate, map[string]string{
"namespace": namespace,
"env": env,
})
}
// ResolveK8sSecretName builds the Kubernetes secret name for a given
// namespace using K8sSecretNameTemplate. Falls back to
// SecretNameTemplate resolved without an env placeholder if
// K8sSecretNameTemplate is not configured, so existing configs keep
// working, though setting it explicitly is recommended since the two
// naming conventions usually differ.
func (c Config) ResolveK8sSecretName(namespace string) string {
template := c.K8sSecretNameTemplate
if template == "" {
template = c.SecretNameTemplate
}
return kctl.ResolveTemplate(template, map[string]string{
"namespace": namespace,
})
}
// ResolveExternalSecretName builds the ExternalSecret CRD object name for
// a given namespace using ExternalSecretNameTemplate. Falls back to
// K8sSecretNameTemplate (or SecretNameTemplate if that is also empty) so
// that existing configs keep working without changes.
func (c Config) ResolveExternalSecretName(namespace string) string {
template := c.ExternalSecretNameTemplate
if template == "" {
template = c.K8sSecretNameTemplate
}
if template == "" {
template = c.SecretNameTemplate
}
return kctl.ResolveTemplate(template, map[string]string{
"namespace": namespace,
})
} }
// DefaultPath returns the default config file location: ~/.kctl-tui/config.yaml // DefaultPath returns the default config file location: ~/.kctl-tui/config.yaml
@@ -27,9 +182,10 @@ func DefaultPath() (string, error) {
} }
// Load reads and parses the config file at path. If the file does not // Load reads and parses the config file at path. If the file does not
// exist, it returns a zero-value Config (no pairs, empty label key) and no // exist, it returns a zero-value Config and no error, so the tool can
// error, so the tool can run with sane defaults before the user has set up // still start (with an explanatory error surfaced later where a required
// a config file. // field turns out to be missing) before the user has set up a config
// file.
func Load(path string) (Config, error) { func Load(path string) (Config, error) {
cfg := Config{} cfg := Config{}
+118 -10
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 (
@@ -11,19 +14,25 @@ func TestLoad_MissingFileReturnsDefaults(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if len(cfg.ContextPairs) != 0 { if len(cfg.Contexts) != 0 || len(cfg.Envs) != 0 {
t.Fatalf("expected no context pairs, got %v", cfg.ContextPairs) t.Fatalf("expected no contexts/envs, got %+v", cfg)
}
if cfg.TeamLabelKey != "" {
t.Fatalf("expected empty team label key, got %q", cfg.TeamLabelKey)
} }
} }
func TestLoad_ValidFile(t *testing.T) { func TestLoad_ValidFile(t *testing.T) {
content := []byte(` content := []byte(`
context_pairs: contexts:
- name: "env-pair-1" - "internal"
contexts: ["ctx-a", "ctx-b"] - "external"
default_context: "internal"
envs:
- "beta"
- "prod"
aws_region: "eu-central-1"
aws_account_id: "123456789012"
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"
team_label_key: "example.org/team" team_label_key: "example.org/team"
`) `)
path := filepath.Join(t.TempDir(), "config.yaml") path := filepath.Join(t.TempDir(), "config.yaml")
@@ -38,7 +47,106 @@ team_label_key: "example.org/team"
if cfg.TeamLabelKey != "example.org/team" { if cfg.TeamLabelKey != "example.org/team" {
t.Fatalf("unexpected team label key: %q", cfg.TeamLabelKey) t.Fatalf("unexpected team label key: %q", cfg.TeamLabelKey)
} }
if len(cfg.ContextPairs) != 1 || cfg.ContextPairs[0].Name != "env-pair-1" { if len(cfg.Contexts) != 2 || len(cfg.Envs) != 2 {
t.Fatalf("unexpected context pairs: %v", cfg.ContextPairs) t.Fatalf("unexpected contexts/envs: %+v", cfg)
}
if cfg.K8sSecretNameTemplate != "{namespace}-common-secrets" {
t.Fatalf("unexpected k8s secret name template: %q", cfg.K8sSecretNameTemplate)
}
}
func TestEffectiveDefaultContext(t *testing.T) {
cfg := Config{Contexts: []string{"internal", "external"}}
if got := cfg.EffectiveDefaultContext(); got != "internal" {
t.Fatalf("expected first context as fallback default, got %q", got)
}
cfg.DefaultContext = "external"
if got := cfg.EffectiveDefaultContext(); got != "external" {
t.Fatalf("expected explicit default_context to win, got %q", got)
}
empty := Config{}
if got := empty.EffectiveDefaultContext(); got != "" {
t.Fatalf("expected empty string when no contexts configured, got %q", got)
}
}
func TestResolveContext(t *testing.T) {
cfg := Config{
AWSRegion: "eu-central-1",
AWSAccountID: "123456789012",
ContextTemplate: "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1",
}
got := cfg.ResolveContext("beta", "internal")
want := "arn:aws:eks:eu-central-1:123456789012:cluster/tf-beta-internal-1"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveSecretName(t *testing.T) {
cfg := Config{SecretNameTemplate: "tf-{namespace}-{env}-secrets"}
got := cfg.ResolveSecretName("example-ns", "beta")
want := "tf-example-ns-beta-secrets"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveK8sSecretName_ExplicitTemplate(t *testing.T) {
cfg := Config{K8sSecretNameTemplate: "{namespace}-common-secrets"}
got := cfg.ResolveK8sSecretName("example-ns")
want := "example-ns-common-secrets"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveK8sSecretName_FallsBackToSecretNameTemplate(t *testing.T) {
cfg := Config{SecretNameTemplate: "tf-{namespace}-{env}-secrets"}
got := cfg.ResolveK8sSecretName("example-ns")
want := "tf-example-ns-{env}-secrets" // {env} intentionally left unresolved here
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveExternalSecretName_ExplicitTemplate(t *testing.T) {
cfg := Config{ExternalSecretNameTemplate: "{namespace}"}
got := cfg.ResolveExternalSecretName("job-apply")
want := "job-apply"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveExternalSecretName_FallsBackToK8sSecretNameTemplate(t *testing.T) {
cfg := Config{K8sSecretNameTemplate: "{namespace}-common-secrets"}
got := cfg.ResolveExternalSecretName("job-apply")
want := "job-apply-common-secrets"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveExternalSecretName_FallsBackToSecretNameTemplate(t *testing.T) {
cfg := Config{SecretNameTemplate: "tf-{namespace}-{env}-secrets"}
got := cfg.ResolveExternalSecretName("job-apply")
want := "tf-job-apply-{env}-secrets" // {env} intentionally left unresolved
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestLoginCommand_DefaultsWhenUnset(t *testing.T) {
cfg := Config{}
if got := cfg.LoginCommand(); got != DefaultAWSSSOLoginCommand {
t.Fatalf("got %q, want default %q", got, DefaultAWSSSOLoginCommand)
}
cfg.AWSSSOLoginCommand = "aws sso login --profile custom"
if got := cfg.LoginCommand(); got != "aws sso login --profile custom" {
t.Fatalf("expected custom login command to be used, got %q", got)
} }
} }
+77
View File
@@ -0,0 +1,77 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl
import "sort"
// SecretDiffEntry represents the comparison of one key between two secret
// sources (e.g. AWS Secrets Manager vs. a Kubernetes Secret).
type SecretDiffEntry struct {
Key string
Left string // e.g. the AWS Secrets Manager value
Right string // e.g. the decoded Kubernetes secret value
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
// diff entries covering the union of keys present in either map. A key that
// only exists on one side is still reported, with the missing side left as
// an empty string and Match set to false (unless both sides happen to be
// empty strings).
func DiffSecretValues(left, right map[string]string) []SecretDiffEntry {
seen := map[string]bool{}
for k := range left {
seen[k] = true
}
for k := range right {
seen[k] = true
}
keys := make([]string, 0, len(seen))
for k := range seen {
keys = append(keys, k)
}
sort.Strings(keys)
result := make([]SecretDiffEntry, 0, len(keys))
for _, k := range keys {
l := left[k]
r := right[k]
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
}
// AnyMismatch reports whether at least one diff entry does not match.
func AnyMismatch(entries []SecretDiffEntry) bool {
for _, e := range entries {
if !e.Match {
return true
}
}
return false
}
+102
View File
@@ -0,0 +1,102 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl
import "testing"
func TestDiffSecretValues_AllMatch(t *testing.T) {
left := map[string]string{"a": "1", "b": "2"}
right := map[string]string{"a": "1", "b": "2"}
entries := DiffSecretValues(left, right)
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
if AnyMismatch(entries) {
t.Fatalf("expected no mismatch, got %v", entries)
}
}
func TestDiffSecretValues_Mismatch(t *testing.T) {
left := map[string]string{"a": "1", "b": "2"}
right := map[string]string{"a": "1", "b": "different"}
entries := DiffSecretValues(left, right)
if !AnyMismatch(entries) {
t.Fatalf("expected a mismatch, got %v", entries)
}
var bEntry *SecretDiffEntry
for i := range entries {
if entries[i].Key == "b" {
bEntry = &entries[i]
}
}
if bEntry == nil || bEntry.Match {
t.Fatalf("expected key 'b' to be a mismatch, got %v", bEntry)
}
}
func TestDiffSecretValues_KeyOnlyOnOneSide(t *testing.T) {
left := map[string]string{"a": "1", "only-left": "x"}
right := map[string]string{"a": "1", "only-right": "y"}
entries := DiffSecretValues(left, right)
if len(entries) != 3 {
t.Fatalf("expected 3 entries (union of keys), got %d: %v", len(entries), entries)
}
if !AnyMismatch(entries) {
t.Fatalf("expected mismatch due to keys only present on one side")
}
}
func TestDiffSecretValues_EmptyMaps(t *testing.T) {
entries := DiffSecretValues(nil, nil)
if len(entries) != 0 {
t.Fatalf("expected no entries for empty maps, got %v", entries)
}
if AnyMismatch(entries) {
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 (
-35
View File
@@ -1,35 +0,0 @@
// Package kctl contains the core, non-interactive logic of kctl-tui.
// Functions here are pure (no kubectl/tmux side effects) so they can be
// unit tested without a live cluster.
package kctl
// ContextPair groups a set of related kubectl contexts that should be
// switchable via TAB while keeping the same namespace (e.g. staging/prod).
type ContextPair struct {
Name string `yaml:"name"`
Contexts []string `yaml:"contexts"`
}
// FindNextContext returns the next context in the same pair/group as
// current, cycling through the group. Returns "", false if current is not
// part of any configured pair.
func FindNextContext(current string, pairs []ContextPair) (string, bool) {
for _, pair := range pairs {
idx := indexOf(pair.Contexts, current)
if idx == -1 {
continue
}
next := pair.Contexts[(idx+1)%len(pair.Contexts)]
return next, true
}
return "", false
}
func indexOf(items []string, target string) int {
for i, v := range items {
if v == target {
return i
}
}
return -1
}
-48
View File
@@ -1,48 +0,0 @@
package kctl
import "testing"
func TestFindNextContext_TwoWayToggle(t *testing.T) {
pairs := []ContextPair{
{Name: "env-pair-1", Contexts: []string{"ctx-a", "ctx-b"}},
}
next, ok := FindNextContext("ctx-a", pairs)
if !ok || next != "ctx-b" {
t.Fatalf("expected ctx-b, got %q (ok=%v)", next, ok)
}
next, ok = FindNextContext("ctx-b", pairs)
if !ok || next != "ctx-a" {
t.Fatalf("expected ctx-a, got %q (ok=%v)", next, ok)
}
}
func TestFindNextContext_Rotation(t *testing.T) {
pairs := []ContextPair{
{Name: "rotation", Contexts: []string{"a", "b", "c"}},
}
next, ok := FindNextContext("c", pairs)
if !ok || next != "a" {
t.Fatalf("expected wraparound to a, got %q (ok=%v)", next, ok)
}
}
func TestFindNextContext_NotConfigured(t *testing.T) {
pairs := []ContextPair{
{Name: "env-pair-1", Contexts: []string{"ctx-a", "ctx-b"}},
}
_, ok := FindNextContext("unrelated-context", pairs)
if ok {
t.Fatalf("expected ok=false for a context with no configured pair")
}
}
func TestFindNextContext_NoPairsConfigured(t *testing.T) {
_, ok := FindNextContext("ctx-a", nil)
if ok {
t.Fatalf("expected ok=false when no pairs are configured")
}
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl
import "strings"
// ResolveTemplate replaces "{key}" placeholders in template with the
// corresponding value from values. Placeholders with no matching key are
// left untouched, so a misconfigured template is visible (e.g. a literal
// "{typo}" in the result) instead of silently collapsing to an empty
// string.
func ResolveTemplate(template string, values map[string]string) string {
result := template
for k, v := range values {
result = strings.ReplaceAll(result, "{"+k+"}", v)
}
return result
}
+44
View File
@@ -0,0 +1,44 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
package kctl
import "testing"
func TestResolveTemplate_SinglePlaceholder(t *testing.T) {
got := ResolveTemplate("secret-{namespace}", map[string]string{"namespace": "example-ns"})
want := "secret-example-ns"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveTemplate_MultiplePlaceholders(t *testing.T) {
template := "arn:aws:eks:{region}:{account_id}:cluster/tf-{env}-{context}-1"
values := map[string]string{
"region": "eu-central-1",
"account_id": "123456789012",
"env": "beta",
"context": "internal",
}
got := ResolveTemplate(template, values)
want := "arn:aws:eks:eu-central-1:123456789012:cluster/tf-beta-internal-1"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveTemplate_UnknownPlaceholderLeftAsIs(t *testing.T) {
got := ResolveTemplate("secret-{unknown}", map[string]string{"namespace": "example-ns"})
want := "secret-{unknown}"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestResolveTemplate_EmptyTemplate(t *testing.T) {
got := ResolveTemplate("", map[string]string{"namespace": "example-ns"})
if got != "" {
t.Fatalf("expected empty result, got %q", got)
}
}
+107 -59
View File
@@ -1,7 +1,16 @@
// 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
// lives in the kctl package instead. // lives in the kctl package instead.
//
// Every kubectl-related function takes an explicit context argument
// (passed as --context) instead of relying on/mutating the globally
// active kubectl context. This lets the panel act on multiple resolved
// contexts (e.g. beta and prod) without switching global state back and
// forth.
package kubeexec package kubeexec
import ( import (
@@ -13,53 +22,35 @@ 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
} }
// GetContexts returns all configured kubectl context names. // kubectlArgs prepends a --context flag when context is non-empty.
func GetContexts() ([]string, error) { func kubectlArgs(context string, args ...string) []string {
out, err := runOutput("kubectl", "config", "get-contexts", "-o", "name") if context == "" {
if err != nil { return args
return nil, err
} }
if out == "" { return append([]string{"--context", context}, args...)
return []string{}, nil
}
return strings.Split(out, "\n"), nil
}
// GetCurrentContext returns the currently active kubectl context, or an
// empty string if none is set.
func GetCurrentContext() string {
out, _ := runOutput("kubectl", "config", "current-context")
return out
}
// GetCurrentNamespace returns the namespace bound to the current context,
// defaulting to "default" if unset.
func GetCurrentNamespace() string {
out, _ := runOutput("kubectl", "config", "view", "--minify", "-o", "jsonpath={..namespace}")
if out == "" {
return "default"
}
return out
}
// UseContext switches the active kubectl context.
func UseContext(ctx string) error {
_, err := runOutput("kubectl", "config", "use-context", ctx)
return err
}
// SetNamespace binds a namespace to the current kubectl context.
func SetNamespace(ns string) error {
_, err := runOutput("kubectl", "config", "set-context", "--current", "--namespace="+ns)
return err
} }
type nsItem struct { type nsItem struct {
@@ -74,9 +65,10 @@ type nsList struct {
} }
// GetNamespacesWithLabels returns a map of namespace name -> labels for all // GetNamespacesWithLabels returns a map of namespace name -> labels for all
// namespaces visible in the current context. // namespaces visible in the given context.
func GetNamespacesWithLabels() (map[string]map[string]string, error) { func GetNamespacesWithLabels(context string) (map[string]map[string]string, error) {
out, err := runOutput("kubectl", "get", "ns", "-o", "json") args := kubectlArgs(context, "get", "ns", "-o", "json")
out, err := runOutput("kubectl", args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -91,10 +83,11 @@ func GetNamespacesWithLabels() (map[string]map[string]string, error) {
return result, nil return result, nil
} }
// GetDeployments lists deployment names in the given namespace. // GetDeployments lists deployment names in the given context/namespace.
func GetDeployments(namespace string) ([]string, error) { func GetDeployments(context, namespace string) ([]string, error) {
out, err := runOutput("kubectl", "-n", namespace, "get", "deploy", args := kubectlArgs(context, "-n", namespace, "get", "deploy",
"-o", `jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}`) "-o", `jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}`)
out, err := runOutput("kubectl", args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -105,20 +98,40 @@ func GetDeployments(namespace string) ([]string, error) {
} }
// RolloutRestart triggers a rolling restart of a deployment. // RolloutRestart triggers a rolling restart of a deployment.
func RolloutRestart(namespace, deployment string) (string, error) { func RolloutRestart(context, namespace, deployment string) (string, error) {
return runOutput("kubectl", "-n", namespace, "rollout", "restart", "deploy/"+deployment) args := kubectlArgs(context, "-n", namespace, "rollout", "restart", "deploy/"+deployment)
return runOutput("kubectl", args...)
} }
// RolloutStatus waits for and returns the rollout status of a deployment. // RolloutStatus waits for and returns the rollout status of a deployment.
func RolloutStatus(namespace, deployment string) (string, error) { func RolloutStatus(context, namespace, deployment string) (string, error) {
return runOutput("kubectl", "-n", namespace, "rollout", "status", "deploy/"+deployment) args := kubectlArgs(context, "-n", namespace, "rollout", "status", "deploy/"+deployment)
return runOutput("kubectl", args...)
} }
// GetSecretValueBase64 returns the raw (still base64-encoded) value of a // GetSecretAllFields returns all fields of a Kubernetes secret, already
// single field in a Kubernetes secret. // base64-decoded into plain values.
func GetSecretValueBase64(namespace, secretName, field string) (string, error) { func GetSecretAllFields(context, namespace, secretName string) (map[string]string, error) {
path := fmt.Sprintf("jsonpath={.data.%s}", field) args := kubectlArgs(context, "-n", namespace, "get", "secret", secretName, "-o", "json")
return runOutput("kubectl", "-n", namespace, "get", "secret", secretName, "-o", path) out, err := runOutput("kubectl", args...)
if err != nil {
return nil, err
}
var parsed struct {
Data map[string]string `json:"data"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
return nil, err
}
result := make(map[string]string, len(parsed.Data))
for k, v := range parsed.Data {
decoded, err := DecodeBase64(v)
if err != nil {
return nil, fmt.Errorf("failed to decode field %q: %w", k, err)
}
result[k] = decoded
}
return result, nil
} }
// DecodeBase64 decodes a base64-encoded Kubernetes secret value. // DecodeBase64 decodes a base64-encoded Kubernetes secret value.
@@ -132,16 +145,51 @@ func DecodeBase64(value string) (string, error) {
// AnnotateForceSync sets the force-sync annotation on an ExternalSecret // AnnotateForceSync sets the force-sync annotation on an ExternalSecret
// object to trigger an immediate re-sync from the upstream secret store. // object to trigger an immediate re-sync from the upstream secret store.
func AnnotateForceSync(namespace, externalSecretName string, unixTimestamp int64) (string, error) { func AnnotateForceSync(context, namespace, externalSecretName string, unixTimestamp int64) (string, error) {
annotation := fmt.Sprintf("force-sync=%d", unixTimestamp) annotation := fmt.Sprintf("force-sync=%d", unixTimestamp)
return runOutput("kubectl", "-n", namespace, "annotate", "externalsecret", args := kubectlArgs(context, "-n", namespace, "annotate", "externalsecret",
externalSecretName, annotation, "--overwrite") externalSecretName, annotation, "--overwrite")
return runOutput("kubectl", args...)
} }
// GetAWSSecretString fetches the SecretString of an AWS Secrets Manager // GetAWSSecretString fetches the SecretString of an AWS Secrets Manager
// secret via the aws-cli. // secret via the aws-cli. The secret ID is computed from config templates
// (see internal/config), not looked up interactively.
func GetAWSSecretString(secretID, region string) (string, error) { func GetAWSSecretString(secretID, region string) (string, error) {
return runOutput("aws", "secretsmanager", "get-secret-value", return runOutput("aws", "secretsmanager", "get-secret-value",
"--secret-id", secretID, "--region", region, "--secret-id", secretID, "--region", region,
"--query", "SecretString", "--output", "text") "--query", "SecretString", "--output", "text")
} }
// CheckAWSAuth performs a cheap, fast call to verify the current AWS
// credentials/SSO session are valid. Returns nil if authenticated, or the
// underlying error (e.g. an expired SSO session) otherwise.
func CheckAWSAuth() error {
_, err := runOutput("aws", "sts", "get-caller-identity", "--query", "Account", "--output", "text")
return err
}
// RunAWSLogin returns an *exec.Cmd for the given login command (e.g.
// "aws sso login"), split on whitespace. The caller is responsible for
// running it interactively (e.g. via tea.ExecProcess) since SSO login
// typically requires opening a browser and confirming a device code.
func RunAWSLogin(loginCommand string) *exec.Cmd {
parts := strings.Fields(loginCommand)
if len(parts) == 0 {
parts = []string{"aws", "sso", "login"}
}
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
}
]
}