mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-10-12 04:35:10 +00:00
* feat: add --context to forward only selected contexts
kportal forwards every context in the config file. When the file describes
several clusters that host the same services, that means holding connections
open to clusters nobody is using, and - because all contexts are active at once
- every localPort has to be globally unique. The same service therefore lands on
a different local port per cluster, so switching clusters means reconfiguring
the app being developed.
--context limits a run to the contexts you name. It is repeatable and accepts
comma-separated values, and composes with -headless:
kportal --context development-team-a
kportal --context team-a,team-b -headless
kportal --context team-a --context team-b
An unknown name is an error listing the contexts the file defines, so a typo
fails loudly instead of silently forwarding nothing.
Local-port and mDNS-hostname conflicts are now scoped to forwards that could run
at the same time: same context, or both contexts in the active selection. With
no selection every context is active, so existing behaviour is unchanged.
Contexts that are never selected together may reuse the same localPort, which is
what lets a service keep one fixed local port in every cluster.
Validation still covers the whole file, so a problem in a context outside the
selection is still reported and --check does not get weaker. Loading, validating
and filtering are shared by every path that re-reads the config (startup, file
watcher, SIGHUP in both headless and verbose modes), so no path can forget the
selection.
Also fixes a latent identity bug this makes reachable: Forward.ID() is only
"alias:localPort" when an alias is set, which was unique across contexts only
because duplicate local ports were globally rejected. With ports now shareable,
deleting or editing a forward from the TUI could have matched same-alias
forwards in other contexts and silently removed them. Delete and edit are now
scoped to the context the row came from.
* fix(config): keep the running config when no selected context remains
A reload dropped selected contexts that no longer exist in the file.
When all of them vanished, the reload returned an empty config and the
manager stopped every forward after a single rename.
LoadForRuntime now returns an error in that case. The watcher keeps the
previous config, as it does for any other invalid reload.
Refs #80
* fix(ui): apply new table columns after a successful SIGHUP reload
The context selection moved the SIGHUP reload into reloadFromSignal,
which had no way to update the verbose table columns. The function now
takes a callback that runs only after the manager reloads without error.
The verbose table passes SetColumns.
Refs #80
* style: gofmt run_test.go
---------
Co-authored-by: André Cruz <[email protected]>
461 lines
13 KiB
Go
461 lines
13 KiB
Go
// Package complete provides shell completion generation for kportal.
|
|
// It supports bash, zsh, and fish shells with context-aware completions
|
|
// for flags, subcommands, config values, and Kubernetes resources.
|
|
package complete
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// Shell represents a supported shell type
|
|
type Shell string
|
|
|
|
const (
|
|
ShellBash Shell = "bash"
|
|
ShellZsh Shell = "zsh"
|
|
ShellFish Shell = "fish"
|
|
)
|
|
|
|
// Installer handles shell completion installation
|
|
type Installer struct {
|
|
shell Shell
|
|
prefixDir string
|
|
}
|
|
|
|
// NewInstaller creates a new completion installer for the specified shell
|
|
func NewInstaller(shell Shell) *Installer {
|
|
return &Installer{
|
|
shell: shell,
|
|
prefixDir: getDefaultPrefixDir(shell),
|
|
}
|
|
}
|
|
|
|
// getDefaultPrefixDir returns the default completion directory for a shell
|
|
func getDefaultPrefixDir(shell Shell) string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
home = os.Getenv("HOME")
|
|
}
|
|
if home == "" {
|
|
home = "/tmp"
|
|
}
|
|
|
|
switch shell {
|
|
case ShellBash:
|
|
// Check common bash completion directories
|
|
dirs := []string{
|
|
"/etc/bash_completion.d",
|
|
filepath.Join(home, ".local", "share", "bash-completion", "completions"),
|
|
filepath.Join(home, ".bash_completion.d"),
|
|
}
|
|
for _, dir := range dirs {
|
|
if pathExists(dir) {
|
|
return dir
|
|
}
|
|
}
|
|
// Fallback to user dir (best-effort create; Install reports write errors)
|
|
userDir := filepath.Join(home, ".local", "share", "bash-completion", "completions")
|
|
ensureDir(userDir)
|
|
return userDir
|
|
|
|
case ShellZsh:
|
|
dirs := []string{
|
|
filepath.Join(home, ".zsh", "completions"),
|
|
filepath.Join(home, ".oh-my-zsh", "completions"),
|
|
}
|
|
for _, dir := range dirs {
|
|
if pathExists(dir) {
|
|
return dir
|
|
}
|
|
}
|
|
// Fallback to standard zsh site-functions
|
|
usrShare := "/usr/local/share/zsh/site-functions"
|
|
if pathExists(usrShare) {
|
|
return usrShare
|
|
}
|
|
userDir := filepath.Join(home, ".zsh", "completions")
|
|
ensureDir(userDir)
|
|
return userDir
|
|
|
|
case ShellFish:
|
|
dir := filepath.Join(home, ".config", "fish", "completions")
|
|
ensureDir(dir)
|
|
return dir
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// Install installs the completion script for the shell
|
|
func (i *Installer) Install() error {
|
|
script, err := Generate(i.shell)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate completion script: %w", err)
|
|
}
|
|
|
|
filename := i.getCompletionFilename()
|
|
filepath := filepath.Join(i.prefixDir, filename)
|
|
|
|
// Check if already installed
|
|
if pathExists(filepath) {
|
|
return fmt.Errorf("completion file already exists: %s (remove it first to reinstall)", filepath)
|
|
}
|
|
|
|
// #nosec G306 -- completion scripts are non-secret and must be world-readable by the shell
|
|
if err := os.WriteFile(filepath, []byte(script), 0o644); err != nil {
|
|
return fmt.Errorf("failed to write completion file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Uninstall removes the completion script
|
|
func (i *Installer) Uninstall() error {
|
|
filename := i.getCompletionFilename()
|
|
filepath := filepath.Join(i.prefixDir, filename)
|
|
|
|
if err := os.Remove(filepath); err != nil {
|
|
return fmt.Errorf("failed to remove completion file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getCompletionFilename returns the filename for the completion script
|
|
func (i *Installer) getCompletionFilename() string {
|
|
switch i.shell {
|
|
case ShellBash:
|
|
return "_kportal"
|
|
case ShellZsh:
|
|
return "_kportal"
|
|
case ShellFish:
|
|
return "kportal.fish"
|
|
}
|
|
return "_kportal"
|
|
}
|
|
|
|
// Print prints the completion script to stdout
|
|
func Print(shell Shell) error {
|
|
script, err := Generate(shell)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Print(script)
|
|
return nil
|
|
}
|
|
|
|
// Generate generates the completion script for the specified shell
|
|
func Generate(shell Shell) (string, error) {
|
|
switch shell {
|
|
case ShellBash:
|
|
return generateBash()
|
|
case ShellZsh:
|
|
return generateZsh()
|
|
case ShellFish:
|
|
return generateFish()
|
|
default:
|
|
return "", fmt.Errorf("unsupported shell: %s", shell)
|
|
}
|
|
}
|
|
|
|
// generateBash generates bash completion script
|
|
func generateBash() (string, error) {
|
|
var sb strings.Builder
|
|
|
|
bashScript := `# kportal shell completion - bash
|
|
# Generated by kportal
|
|
|
|
# Don't interfere with other completions
|
|
if [[ -n "${BASH_COMPLETION_VERSINFO:-}" ]]; then
|
|
return
|
|
fi
|
|
|
|
_kportal()
|
|
{
|
|
local cur prev words cword split=false
|
|
_init_completion -s || return
|
|
|
|
# Complete the value expected after the previous flag
|
|
case "$prev" in
|
|
-c|--config)
|
|
_filedir yaml
|
|
return
|
|
;;
|
|
--log-format)
|
|
COMPREPLY=( $(compgen -W "text json" -- "$cur") )
|
|
return
|
|
;;
|
|
--convert)
|
|
_filedir json
|
|
return
|
|
;;
|
|
--convert-output)
|
|
_filedir yaml
|
|
return
|
|
;;
|
|
--context)
|
|
# Complete from kubectl contexts
|
|
if command -v kubectl &> /dev/null; then
|
|
COMPREPLY=( $(compgen -W "$(kubectl config get-contexts -o name 2>/dev/null)" -- "$cur") )
|
|
fi
|
|
return
|
|
;;
|
|
--shell)
|
|
COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") )
|
|
return
|
|
;;
|
|
esac
|
|
|
|
# Handle option splitting (e.g., -c=value)
|
|
[[ "$cur" == -*=* ]] && split=true
|
|
[[ "$split" == true ]] && prev="${cur%%=*}"
|
|
|
|
# Subcommand-specific completion
|
|
case "${words[1]}" in
|
|
generate)
|
|
if [[ "$cur" == -* ]]; then
|
|
COMPREPLY=( $(compgen -W "--context --config --dry-run" -- "$cur") )
|
|
elif command -v kubectl &> /dev/null; then
|
|
COMPREPLY=( $(compgen -W "$(kubectl config get-contexts -o name 2>/dev/null)" -- "$cur") )
|
|
fi
|
|
return
|
|
;;
|
|
completion)
|
|
COMPREPLY=( $(compgen -W "--install --uninstall --shell" -- "$cur") )
|
|
return
|
|
;;
|
|
esac
|
|
|
|
# Top-level options
|
|
if [[ "$cur" == -* ]]; then
|
|
COMPREPLY=( $(compgen -W "-c -v --check --context --headless --log-format --version --update --convert --convert-output" -- "$cur") )
|
|
return
|
|
fi
|
|
|
|
# Top-level subcommands
|
|
COMPREPLY=( $(compgen -W "generate completion" -- "$cur") )
|
|
}
|
|
|
|
# Register completion
|
|
complete -F _kportal kportal
|
|
|
|
# Also complete for common aliases
|
|
complete -F _kportal kp
|
|
`
|
|
|
|
sb.WriteString(bashScript)
|
|
return sb.String(), nil
|
|
}
|
|
|
|
// generateZsh generates zsh completion script
|
|
func generateZsh() (string, error) {
|
|
var sb strings.Builder
|
|
|
|
// Per-flag zsh _arguments descriptors. Completion scripts are static text, so
|
|
// these are literals (single source of truth — keep in sync with the CLI flags).
|
|
flagDescs := []string{
|
|
`'-c[Path to configuration file]:config file:_files -g "*.yaml"'`,
|
|
`'*--context[Only forward the named contexts from the config file]:context:($(kubectl config get-contexts -o name 2>/dev/null))'`,
|
|
`'-v[Enable verbose logging]'`,
|
|
`'--version[Show version and exit]'`,
|
|
`'--update[Check for updates]'`,
|
|
`'--check[Validate configuration]'`,
|
|
`'--headless[Run without UI]'`,
|
|
`'--log-format[Log format: text or json]:format:(text json)'`,
|
|
`'--convert[Convert kftray config]:input file:_files -g "*.json"'`,
|
|
`'--convert-output[Output file]:output file:_files -g "*.yaml"'`,
|
|
}
|
|
|
|
sb.WriteString(`#compdef kportal
|
|
|
|
# kportal shell completion - zsh
|
|
# Generated by kportal
|
|
|
|
_kportal()
|
|
{
|
|
local -a commands flags generate_flags completion_flags
|
|
|
|
commands=(
|
|
'generate:Interactively generate forwards from cluster'
|
|
'completion:Generate shell completion scripts'
|
|
)
|
|
|
|
flags=(
|
|
`)
|
|
|
|
for _, desc := range flagDescs {
|
|
fmt.Fprintf(&sb, " %s\n", desc)
|
|
}
|
|
|
|
sb.WriteString(` )
|
|
|
|
generate_flags=(
|
|
'--context[Kubernetes context]:context:->ctx'
|
|
'--config[Config file]:file:_files -g "*.yaml"'
|
|
'--dry-run[Print without saving]'
|
|
)
|
|
|
|
completion_flags=(
|
|
'--install[Install completions for the shell]'
|
|
'--uninstall[Remove installed completions]'
|
|
'--shell[Shell type]:shell:(bash zsh fish)'
|
|
)
|
|
|
|
_arguments -s $flags '1: :->command' '*:: :->args'
|
|
|
|
case $state in
|
|
command)
|
|
_describe 'command' commands
|
|
;;
|
|
args)
|
|
case ${words[1]} in
|
|
generate)
|
|
_arguments -s $generate_flags
|
|
if [[ "$words[CURRENT]" == --context ]]; then
|
|
if command -v kubectl &> /dev/null; then
|
|
local -a contexts
|
|
contexts=(${(f)"$(kubectl config get-contexts -o name 2>/dev/null)"})
|
|
_describe 'context' contexts
|
|
fi
|
|
fi
|
|
;;
|
|
completion)
|
|
_arguments -s $completion_flags
|
|
;;
|
|
esac
|
|
;;
|
|
esac
|
|
}
|
|
|
|
_kportal "$@"
|
|
`)
|
|
|
|
return sb.String(), nil
|
|
}
|
|
|
|
// generateFish generates fish completion script
|
|
func generateFish() (string, error) {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(`# kportal shell completion - fish
|
|
# Generated by kportal
|
|
|
|
# Main completion
|
|
complete -c kportal -f
|
|
|
|
# Subcommands
|
|
complete -c kportal -n '__fish_use_subcommand' -a 'generate' -d 'Interactively generate forwards from cluster'
|
|
complete -c kportal -n '__fish_use_subcommand' -a 'completion' -d 'Generate shell completion scripts'
|
|
|
|
# Global flags (main command uses single-dash -c and -v; words accept --)
|
|
complete -c kportal -s c -r -f -a '( __fish_complete_suffix .yaml )' -d 'Path to configuration file'
|
|
complete -c kportal -s v -d 'Enable verbose logging'
|
|
complete -c kportal -l version -d 'Show version and exit'
|
|
complete -c kportal -l update -d 'Check for updates'
|
|
complete -c kportal -l check -d 'Validate configuration'
|
|
complete -c kportal -n 'not __fish_seen_subcommand_from generate completion' -l context -r -d 'Only forward the named contexts' -a '(kubectl config get-contexts -o name 2>/dev/null)' -f
|
|
complete -c kportal -l headless -d 'Run without UI'
|
|
complete -c kportal -l log-format -d 'Log format' -a 'text json' -f
|
|
complete -c kportal -l convert -r -f -a '( __fish_complete_suffix .json )' -d 'Convert kftray config'
|
|
complete -c kportal -l convert-output -r -f -a '( __fish_complete_suffix .yaml )' -d 'Output file'
|
|
|
|
# generate subcommand flags
|
|
complete -c kportal -n '__fish_seen_subcommand_from generate' -l context -d 'Kubernetes context' -a '(kubectl config get-contexts -o name 2>/dev/null)' -f
|
|
complete -c kportal -n '__fish_seen_subcommand_from generate' -l config -r -f -a '( __fish_complete_suffix .yaml )' -d 'Config file'
|
|
complete -c kportal -n '__fish_seen_subcommand_from generate' -l dry-run -d 'Print without saving'
|
|
|
|
# completion subcommand flags
|
|
complete -c kportal -n '__fish_seen_subcommand_from completion' -l install -d 'Install completions for the shell'
|
|
complete -c kportal -n '__fish_seen_subcommand_from completion' -l uninstall -d 'Remove installed completions'
|
|
complete -c kportal -n '__fish_seen_subcommand_from completion' -l shell -d 'Shell type' -a 'bash zsh fish' -f
|
|
|
|
# Aliases
|
|
complete -c kp -f
|
|
`)
|
|
|
|
return sb.String(), nil
|
|
}
|
|
|
|
// InstallCompletions installs completions for the specified shell
|
|
// Prints instructions for manual installation if auto-install fails
|
|
func InstallCompletions(shell Shell) error {
|
|
installer := NewInstaller(shell)
|
|
|
|
if err := installer.Install(); err != nil {
|
|
// Check if it's just "already exists" error
|
|
if strings.Contains(err.Error(), "already exists") {
|
|
fmt.Printf("Completion already installed at: %s/%s\n",
|
|
installer.prefixDir, installer.getCompletionFilename())
|
|
fmt.Println("Remove it first to reinstall, or source it manually:")
|
|
return nil
|
|
}
|
|
|
|
// Try to print the script instead
|
|
fmt.Println("Could not auto-install completions. To install manually, run:")
|
|
fmt.Printf(" # %s\n", getInstallInstructions(shell))
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("✅ Completions installed to: %s/%s\n",
|
|
installer.prefixDir, installer.getCompletionFilename())
|
|
fmt.Printf("\nTo enable %s completions, restart your shell or run:\n", shell)
|
|
fmt.Printf(" source ~/%s\n", getSourceInstruction(shell))
|
|
|
|
return nil
|
|
}
|
|
|
|
func getInstallInstructions(shell Shell) string {
|
|
return fmt.Sprintf("kportal completion %s > <your-completion-dir>/_kportal", shell)
|
|
}
|
|
|
|
func getSourceInstruction(shell Shell) string {
|
|
switch shell {
|
|
case ShellBash:
|
|
return ".bashrc"
|
|
case ShellZsh:
|
|
return ".zshrc"
|
|
case ShellFish:
|
|
return ".config/fish/config.fish # completions load automatically"
|
|
}
|
|
return "shell config"
|
|
}
|
|
|
|
// AutoDetectShell detects the current shell
|
|
func AutoDetectShell() Shell {
|
|
shell := os.Getenv("SHELL")
|
|
if shell == "" {
|
|
return ShellBash // Default
|
|
}
|
|
|
|
if strings.HasSuffix(shell, "/bash") {
|
|
return ShellBash
|
|
}
|
|
if strings.HasSuffix(shell, "/zsh") {
|
|
return ShellZsh
|
|
}
|
|
if strings.HasSuffix(shell, "/fish") {
|
|
return ShellFish
|
|
}
|
|
|
|
return ShellBash
|
|
}
|
|
|
|
// pathExists reports whether path exists on disk.
|
|
// #nosec G703 -- callers pass fixed completion paths derived from the user's own
|
|
// HOME and constant subdirectory names, never external/untrusted input.
|
|
func pathExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
// ensureDir best-effort creates a completion directory. Errors are intentionally
|
|
// ignored: Install() surfaces any later write failure with a clear message.
|
|
// #nosec G703 -- path is derived from the user's own HOME and constant
|
|
// subdirectory names; 0o750 is appropriate for user-owned completion dirs.
|
|
func ensureDir(path string) {
|
|
_ = os.MkdirAll(path, 0o750)
|
|
}
|