diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 72cbde3..709a691 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -33,3 +33,26 @@ jobs: run: | go test ./... -coverprofile=coverage.out go tool cover -func=coverage.out + + goreleaser: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-go@v7 + with: + go-version: '1.26' + # Catch a broken release config here rather than halfway through a tag. + - name: Validate release config + uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: check + - name: Build snapshot + uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: release --snapshot --clean --skip=publish + env: + HOMEBREW_TAP_GITHUB_TOKEN: '' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f570eda..31366db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: push: tags: [ '*' ] +permissions: + contents: write + jobs: lint: runs-on: ubuntu-24.04 @@ -21,18 +24,29 @@ jobs: steps: - name: Check out code 🛒 uses: actions/checkout@v7 + with: + # GoReleaser needs full history and tags to resolve the version. + fetch-depth: 0 + + - name: Set up Go 🐹 + uses: actions/setup-go@v7 + with: + go-version: '1.26' - name: Generate Release Notes 📝 - if: github.ref_type == 'tag' + # Written outside the working tree: GoReleaser refuses to release from + # a dirty git state, and an untracked changelog.txt counts as dirty. run: | CHANGE=$(sed "/${GITHUB_REF_NAME%%-*}/,/##/!d;//d" CHANGELOG.md | awk 'NF') - echo "$CHANGE" > changelog.txt - RELEASE_TYPE=$(if [[ "$GITHUB_REF_NAME" =~ "beta" ]]; then echo "true"; else echo "false"; fi) - echo "RELEASE_TYPE=${RELEASE_TYPE}" >> $GITHUB_ENV + echo "$CHANGE" > "${RUNNER_TEMP}/changelog.txt" - - name: Create Release 🎉 - uses: ncipollo/release-action@v1.21.0 - if: github.ref_type == 'tag' + - name: Run GoReleaser 🚀 + uses: goreleaser/goreleaser-action@v6 with: - bodyFile: "changelog.txt" - prerelease: ${{ env.RELEASE_TYPE }} + version: '~> v2' + args: release --clean --release-notes=${{ runner.temp }}/changelog.txt + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Needs write access to radiusmethod/homebrew-awsd. Without it the + # release still publishes; only the tap update is skipped. + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index fcfaeff..53f684f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ bin/ # IDEs .idea .vscode +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..86c841e --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,96 @@ +version: 2 + +project_name: kxd + +before: + hooks: + - go mod tidy + +builds: + - id: kxd + binary: kxd + main: . + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + ldflags: + # version is otherwise "dev"; the tag is the source of truth for releases. + - -s -w -X github.com/radiusmethod/kxd/src/cmd.version=v{{ .Version }} + +archives: + - id: kxd + ids: + - kxd + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + files: + - README.md + - LICENSE + - CHANGELOG.md + format_overrides: + - goos: windows + formats: + - zip + +checksum: + name_template: "checksums.txt" + +changelog: + # Release notes come from CHANGELOG.md via --release-notes in the workflow. + disable: true + +release: + prerelease: auto + # Make re-runs idempotent. Without this, retrying a tag after a partial + # failure (a bad tap token, say) trips over the release that already exists. + mode: replace + +homebrew_casks: + - name: kxd + ids: + - kxd + repository: + owner: radiusmethod + name: homebrew-kxd + branch: main + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + directory: Casks + homepage: "https://github.com/radiusmethod/kxd" + description: "Kubeconfig and context switcher in Go" + license: "MIT" + # Ship binaries even when the tap token is not configured, rather than + # failing the whole release. The workflow always defines the variable, so + # this is empty rather than missing when the secret is absent. + skip_upload: '{{ if .Env.HOMEBREW_TAP_GITHUB_TOKEN }}false{{ else }}true{{ end }}' + hooks: + post: + # The binaries are unsigned, so macOS quarantines them on download and + # Gatekeeper refuses to run them. Strip the attribute on install. + install: | + if system_command("/usr/bin/xattr", args: ["-h"]).exit_status == 0 + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/kxd"] + end + caveats: | + To finish setting up kxd, add the line for your shell to its startup file: + + zsh eval "$(kxd init zsh)" + bash eval "$(kxd init bash)" + fish kxd init fish | source + PowerShell kxd init powershell | Out-String | Invoke-Expression + + Then open a new terminal, or source that file. + + Upgrading from before v0.2.0? The line above replaces all of these, so + remove them from your shell config: + + alias kxd="source _kxd" + source _kxd_autocomplete + export KUBECONFIG=$(kxd file current) + + Then delete any leftover _kxd_prompt, _kxd, and _kxd_autocomplete files + from earlier manual installs. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0720cf8..e8bcc18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ +## v0.2.0 (August 5, 2026) + +**Breaking.** kxd is now a single binary named `kxd`, set up with one line in your shell rc file. Pre-1.0, so no compatibility shims. + +* Added `kxd init ` — `eval "$(kxd init zsh)"` now replaces the `kxd` alias, the completion `source`, and the `export KUBECONFIG=$(kxd file current)` persistence line. Supports bash, zsh, fish, and PowerShell. +* Added `kxd shellenv [shell]`, which prints the export/unset statement for the active kubeconfig. This is what the generated function evals. +* Added fish support. PowerShell setup is now one line instead of building a binary and dot-sourcing two scripts by hand. +* The binary is installed as `kxd`, not `_kxd_prompt`. The generated integration calls it through `command kxd`, which skips the shell function of the same name. +* Removed `scripts/` entirely: `_kxd`, `_kxd_autocomplete`, `_kxd.ps1`, and `_kxd_autocomplete.ps1`. `make install` now installs one file. +* `~/.kxd` parsing and the `~/.kube` path join now live only in the Go code, instead of being reimplemented in every wrapper script. +* Values are shell-quoted, so kubeconfig filenames containing spaces or quotes work. +* Completion now covers the `file`/`context`/`namespace` subcommands and their arguments, not just top-level config names. Namespace values are excluded on purpose, since listing them calls the live cluster. +* **Behavior change:** `kxd ` now writes its warning to stderr and exits 1, instead of writing to stdout and exiting 0. Scripts relying on the old exit code need updating. This keeps ANSI color codes out of the command substitutions the shell integration evals. + +Releases are now built by GoReleaser: + +* Releases ship prebuilt binaries for macOS, Linux, and Windows on amd64 and arm64, with `checksums.txt`. Installing no longer compiles from source, and Windows binaries are published for the first time. +* Homebrew distribution moves from a formula to a **cask**, generated on each tag. Reinstall with `brew uninstall kxd && brew install radiusmethod/kxd/kxd` if brew complains about the change. +* `kxd version` now reports the git tag, injected at build time. `make install` derives it from `git describe`, and a plain `go build` reports `dev`. +* Prerelease tags must now be SemVer-hyphenated (`v0.3.0-beta1`), not `v0.3.0beta`. + +To upgrade: replace your shell config lines as described in "Upgrading from pre-v0.2.0" in the README, then delete any leftover `_kxd_prompt`, `_kxd`, and `_kxd_autocomplete` files. Your `~/.kxd` file is unchanged and carries over. + ## v0.1.4 (May 22, 2026) * Updated Kubernetes client libraries (client-go v0.36.1), cobra (v1.10.2), and Go toolchain to 1.26. diff --git a/Makefile b/Makefile index ff4222a..7550fd6 100644 --- a/Makefile +++ b/Makefile @@ -1,33 +1,31 @@ BINDIR = /usr/local/bin +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS = -s -w -X github.com/radiusmethod/kxd/src/cmd.version=$(VERSION) help: ## Show this help @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' install: ## Install Target - GOOS= GOARCH= GOARM= GOFLAGS= go build -o ${BINDIR}/_kxd_prompt - cp scripts/_kxd ${BINDIR}/_kxd - cp scripts/_kxd_autocomplete ${BINDIR}/_kxd_autocomplete - @echo " -=-=--=-=-=-=-=-=-=-=-=-=-=-=- " - @echo " __ __ _ __ ____ " - @echo " / //_/ | |/ / / __ \ " - @echo " / ,< | / / / / / " - @echo " / /| | / | / /_/ / " - @echo " /_/ |_| /_/|_| /_____/ " - @echo " " - @echo " To Finish Installation add " - @echo " " - @echo " alias kxd=\"source _kxd\" " - @echo " " - @echo " to your bash profile or zshrc " - @echo " then open new terminal or " - @echo " source that file " - @echo " " - @echo " -=-=--=-=-=-=-=-=-=-=-=-=-=-=- " + GOOS= GOARCH= GOARM= GOFLAGS= go build -ldflags="$(LDFLAGS)" -o ${BINDIR}/kxd + @echo " -=-=--=-=-=-=-=-=-=-=-=-=-=-=-=-=- " + @echo " __ __ _ __ ____ " + @echo " / //_/ | |/ / / __ \ " + @echo " / ,< | / / / / / " + @echo " / /| | / | / /_/ / " + @echo " /_/ |_| /_/|_| /_____/ " + @echo " " + @echo " To Finish Installation add " + @echo " " + @echo " eval \"\$$(kxd init zsh)\" " + @echo " " + @echo " to your zshrc (or bash profile, " + @echo " with 'init bash') then open a new " + @echo " terminal or source that file " + @echo " " + @echo " -=-=--=-=-=-=-=-=-=-=-=-=-=-=-=-=- " uninstall: ## Uninstall Target - rm -f ${BINDIR}/_kxd - rm -f ${BINDIR}/_kxd_autocomplete - rm -f ${BINDIR}/_kxd_prompt + rm -f ${BINDIR}/kxd .PHONY: test test-coverage docs test: ## Run tests diff --git a/Makefile_Windows b/Makefile_Windows index ca8e431..3a9faa0 100644 --- a/Makefile_Windows +++ b/Makefile_Windows @@ -1,14 +1,7 @@ -BINDIR = C:\tools - -# Requires `make` with PowerShell as the shell. Easiest way: -# make -f Makefile_Windows SHELL=pwsh.exe install -# `cp` / `rm` resolve to PowerShell aliases, `New-Item` is PowerShell-native. +BINDIR = C:\tools\kxd install: ## Install Target - go build -o ${BINDIR}/kxd/_kxd_prompt.exe - cp scripts/_kxd.ps1 ${BINDIR}/kxd/_kxd.ps1 - cp scripts/_kxd_autocomplete.ps1 ${BINDIR}/kxd/_kxd_autocomplete.ps1 - New-Item -ItemType File -Path ~/.kxd -Force + go build -o ${BINDIR}\kxd.exe uninstall: ## Uninstall Target - rm -Force -Recurse ${BINDIR}/kxd + rm -Force ${BINDIR}\kxd.exe diff --git a/README.md b/README.md index 07f69a4..7d0d541 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,11 @@ kxd is a command-line utility that allows you to easily switch between Kubernete - [Installation](#installation) - [Homebrew](#homebrew) + - [Prebuilt binary](#prebuilt-binary) - [Makefile](#makefile) - [To Finish Installation](#to-finish-installation) - [Upgrading](#upgrading) + - [Upgrading from pre-v0.2.0](#upgrading-from-pre-v020) - [Windows](#windows) - [Configuration](#configuration) - [Usage](#usage) @@ -27,17 +29,14 @@ kxd is a command-line utility that allows you to easily switch between Kubernete - [Switching Kubernetes Context Namespaces](#switching-kubernetes-context-namespaces) - [Getting Current Kubeconfig, Kubernetes Context or Context Namespace](#getting-current-kubeconfig-kubernetes-context-or-context-namespace) - [Version](#version) - - [Persist KUBECONFIG across new shells](#persist-kubeconfig-across-new-shells) - [Show your set kubeconfig in your shell prompt](#show-your-set-kubeconfig-in-your-shell-prompt) - [Add autocompletion](#add-autocompletion) - - [TL;DR (full config example)](#tldr-full-config-example) +- [Why a shell function?](#why-a-shell-function) - [Contributing](#contributing) - [License](#license) ## Installation -Make sure you have Go installed. You can download it from [here](https://golang.org/dl/). - ### Homebrew ```bash @@ -50,20 +49,48 @@ or just ```bash brew install radiusmethod/kxd/kxd ``` + +### Prebuilt binary + +Grab the archive for your platform from the +[latest release](https://github.com/radiusmethod/kxd/releases/latest), then put `kxd` somewhere on +your `PATH`. macOS, Linux, and Windows on amd64 and arm64. + ### Makefile +Builds from source, so this one needs [Go](https://golang.org/dl/) installed. + ```bash make install ``` ### To Finish Installation -Add the following to your bash profile or zshrc then open new terminal or source that file +Add one line to your shell's startup file, then open a new terminal or source that file. +**zsh** (`~/.zshrc`): ```sh -alias kxd="source _kxd" +eval "$(kxd init zsh)" ``` -Ex. `echo -ne '\nalias kxd="source _kxd"' >> ~/.zshrc` +**bash** (`~/.bashrc` or `~/.bash_profile`): +```sh +eval "$(kxd init bash)" +``` + +**fish** (`~/.config/fish/config.fish`): +```fish +kxd init fish | source +``` + +**PowerShell** (`$PROFILE`): +```powershell +kxd init powershell | Out-String | Invoke-Expression +``` + +Ex. `echo 'eval "$(kxd init zsh)"' >> ~/.zshrc` + +That one line defines the `kxd` shell function, sets up tab completion, and applies the kubeconfig +you last selected to every new shell. Nothing else to configure. ### Upgrading Upgrading consists of just doing a brew update and brew upgrade. @@ -72,63 +99,82 @@ Upgrading consists of just doing a brew update and brew upgrade. brew update && brew upgrade radiusmethod/kxd/kxd ``` -### Windows +### Upgrading from pre-v0.2.0 +**v0.2.0 is a breaking change.** kxd now installs one binary named `kxd`. The old `_kxd_prompt` +binary, the `_kxd` wrapper script, `_kxd_autocomplete`, and both `.ps1` wrappers are gone, along +with the alias-based setup. + +The single `eval` line above replaces all of this, so delete whatever you have of it: -`kxd` is designed for POSIX shells (bash/zsh): the Go binary writes the user's selection to `~/.kxd`, then a wrapper script that you `source` reads that file and exports `KUBECONFIG` into your current shell. A child process can't mutate its parent's environment, so the wrapper indirection is mandatory — and that's what makes Windows non-trivial. Three paths work: +```sh +alias kxd="source _kxd" # removed in v0.2.0 +source _kxd_autocomplete # removed in v0.2.0 +export KUBECONFIG=$(kxd file current) # no longer needed, init applies it +``` -#### WSL (recommended) +Keep your `KXD_MATCHER` line if you set one. Then clear out the old files, which a package manager +will not remove for you if you ever ran `make install` by hand: -From a WSL2 Ubuntu/Debian shell, follow the standard Linux instructions exactly: `make install`, then add `alias kxd="source _kxd"` to `~/.bashrc` or `~/.zshrc`. From WSL's perspective it's just Linux. +```sh +rm -f /usr/local/bin/_kxd_prompt /usr/local/bin/_kxd /usr/local/bin/_kxd_autocomplete +type -a _kxd_prompt # should print nothing +``` -Caveat: the `KUBECONFIG` you set inside WSL is **not** visible to `kubectl.exe` invoked from PowerShell or `cmd`. Run `kubectl` from WSL too, or set the env var separately on the Windows side. +Two things that bite during the upgrade: -#### Git Bash / MSYS2 +- **Remove the old alias.** In zsh an alias shadows a function of the same name, so leaving + `alias kxd="source _kxd"` in place means the new `kxd` function never gets used. If the alias is + defined *before* the `eval` line, the eval fails outright with + `defining function based on alias 'kxd'`. +- **Put the `eval` line after any `PATH` changes** that point at your kxd install, and after + `KXD_MATCHER` is exported. It runs `kxd` at startup, so an older copy earlier in `PATH` at that + moment produces confusing errors. `type -a kxd` shows you every copy. -The Go binary cross-compiles cleanly and the bash wrapper is portable enough for Git Bash to `source`. Manual setup: +Your `~/.kxd` file carries over untouched, so your selected kubeconfig survives the upgrade. -1. Build the Windows binary: - ```sh - GOOS=windows GOARCH=amd64 go build -o _kxd_prompt.exe . - ``` -2. Copy `_kxd_prompt.exe`, `scripts/_kxd`, and `scripts/_kxd_autocomplete` to a directory on your Git Bash `PATH` (e.g. `~/bin`). -3. Add to `~/.bashrc`: - ```sh - alias kxd="source _kxd" - source _kxd_autocomplete - ``` -4. Make sure `~/.kube/` exists with your config files. In Git Bash, `~` resolves to `C:\Users\`. +### Windows -Untested by the maintainers. `~/.kube/config` symlinks created on the Windows side sometimes confuse path resolution. +Releases include Windows binaries for amd64 and arm64, and `kxd init powershell` generates the +PowerShell integration, so there is nothing to copy by hand. #### Native PowerShell -`scripts/_kxd.ps1` and `scripts/_kxd_autocomplete.ps1` are PowerShell equivalents of the bash wrapper and autocomplete. +Download the Windows archive from the +[latest release](https://github.com/radiusmethod/kxd/releases/latest), put `kxd.exe` on your +`$env:PATH`, then add this to your profile (open it with `notepad $PROFILE`): + +```powershell +kxd init powershell | Out-String | Invoke-Expression +``` + +Restart PowerShell. `kxd` is now a function in your session, with tab completion, and your +selected kubeconfig is applied to every new session. -If you have `make` available on Windows, `Makefile_Windows` collapses steps 1–2 below into a single command: +Building from source instead needs [Go](https://golang.org/dl/): ```powershell -make -f Makefile_Windows SHELL=pwsh.exe install +make -f Makefile_Windows install # builds kxd.exe into C:\tools\kxd, override with BINDIR=... ``` -That builds `_kxd_prompt.exe` and drops both `.ps1` files into `C:\tools\kxd\` (override with `BINDIR=...`). You still need to add that directory to `$env:PATH` and dot-source the scripts from your `$PROFILE` (step 3 below). +#### WSL -Otherwise, manual setup: +From a WSL2 Ubuntu/Debian shell, follow the standard Linux instructions exactly. From WSL's +perspective it's just Linux. -1. Build the Windows binary and put it somewhere on `$env:PATH`: - ```powershell - $env:GOOS = "windows"; $env:GOARCH = "amd64" - go build -o _kxd_prompt.exe . - # move _kxd_prompt.exe into e.g. C:\Users\\bin - ``` -2. Copy `scripts/_kxd.ps1` and `scripts/_kxd_autocomplete.ps1` somewhere persistent (e.g. `C:\Users\\bin`). -3. Dot-source both from your PowerShell profile (open it with `notepad $PROFILE`): - ```powershell - . "$HOME\bin\_kxd.ps1" - . "$HOME\bin\_kxd_autocomplete.ps1" - ``` -4. Restart PowerShell. `kxd` is now a function in your session. +Caveat: the `KUBECONFIG` you set inside WSL is **not** visible to `kubectl.exe` invoked from +PowerShell or `cmd`. Run `kubectl` from WSL too, or set the env var separately on the Windows side. -The function reads/writes `$HOME\.kxd` and `$HOME\.kube\` — same layout as the POSIX version, so configs interoperate with WSL or Git Bash on the same machine if you point them at the same `.kube` directory. +#### Git Bash / MSYS2 + +Put `kxd.exe` on your Git Bash `PATH` and add `eval "$(kxd init bash)"` to `~/.bashrc`. Make sure +`~/.kube/` exists with your config files; in Git Bash, `~` resolves to `C:\Users\`. + +Untested by the maintainers. `~/.kube/config` symlinks created on the Windows side sometimes +confuse path resolution. + +kxd reads and writes `$HOME\.kxd` and `$HOME\.kube\` on every platform, so configs +interoperate between PowerShell, WSL, and Git Bash on the same machine if you point them at the +same `.kube` directory. ## Configuration @@ -209,12 +255,8 @@ To check the version of Kubeconfig Switcher, use the following command: kxd version ``` -## Persist KUBECONFIG across new shells -To persist the set config when you open new terminal windows, you can add the following to your bash profile or zshrc. - -```bash -export KUBECONFIG=$(kxd file current) -``` +Your selection persists across new terminal windows automatically, since `kxd init` applies +whatever is in `~/.kxd` when each shell starts. ### Show your set kubeconfig in your shell prompt For better visibility into what your shell is set to it can be helpful to configure your prompt to show the value of the env variable `KUBECONFIG`. @@ -273,17 +315,39 @@ Then add `kxd` to either your left or right prompt segments. ## Add autocompletion -You can add autocompletion when passing config as argument by adding the following to your bash profile or zshrc file. -`source _kxd_autocomplete` +Tab completion comes with `kxd init`. Type `kxd my-k`, hit tab, and a config named +`my-kubeconfig.conf` completes. It also completes the `file`/`context`/`namespace` subcommands and +their `switch`/`current`/`list` arguments, so `kxd file switch ` lists your configs and +`kxd context switch ` lists contexts. + +Namespaces are deliberately not completed: `kxd namespace list` queries the live cluster, and +blocking your shell on a network round trip every time you press tab is worse than no completion. + +## Why a shell function? + +`kxd init` generates a shell function rather than shipping a plain binary, because a child process +cannot change its parent shell's environment. Anything that sets `KUBECONFIG` for your current +shell has to run *in* that shell. + +So the binary does the picking and writes your choice to `~/.kxd`, and the generated function asks +it for the matching shell code and evals that: + +```sh +kxd() { + command kxd "$@" || return + eval "$(command kxd shellenv bash)" +} +``` + +The function and the binary share the name `kxd`. That works because `command` skips functions and +aliases and runs the executable from `PATH`. PowerShell's `&` operator does not do this, so the +generated PowerShell integration resolves the binary path up front with `Get-Command` instead. -Now you can do `kxd my-k` and hit tab and if you had a config `my-kubeconfig` it would autocomplete and find it. +You can see exactly what gets eval'd at any time: -## TL;DR (full config example) ```sh -alias kxd="source _kxd" -source _kxd_autocomplete -export KXD_MATCHER="-config,.conf" -export KUBECONFIG=$(kxd file current) +kxd init zsh # the whole integration +kxd shellenv zsh # just the export for the current selection ``` ## Contributing diff --git a/docs/kxd.md b/docs/kxd.md index 9775c2f..a5046a1 100644 --- a/docs/kxd.md +++ b/docs/kxd.md @@ -20,7 +20,9 @@ kxd [flags] * [kxd context](kxd_context.md) - Kubeconfig context command * [kxd file](kxd_file.md) - Kubeconfig file command +* [kxd init](kxd_init.md) - Print the shell integration for the given shell. * [kxd namespace](kxd_namespace.md) - Kubeconfig namespace command +* [kxd shellenv](kxd_shellenv.md) - Print the shell code that applies ~/.kxd to the current shell. * [kxd version](kxd_version.md) - kxd version command -###### Auto generated by spf13/cobra on 22-May-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/kxd_context.md b/docs/kxd_context.md index 07f4ad7..40deb4c 100644 --- a/docs/kxd_context.md +++ b/docs/kxd_context.md @@ -19,4 +19,4 @@ This is the default context command. * [kxd context list](kxd_context_list.md) - List kubeconfig contexts * [kxd context switch](kxd_context_switch.md) - Switch kubeconfig contexts -###### Auto generated by spf13/cobra on 22-May-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/kxd_context_current.md b/docs/kxd_context_current.md index 71dfd65..f93c8fc 100644 --- a/docs/kxd_context_current.md +++ b/docs/kxd_context_current.md @@ -20,4 +20,4 @@ kxd context current [flags] * [kxd context](kxd_context.md) - Kubeconfig context command -###### Auto generated by spf13/cobra on 22-May-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/kxd_context_list.md b/docs/kxd_context_list.md index bada83b..f5636c6 100644 --- a/docs/kxd_context_list.md +++ b/docs/kxd_context_list.md @@ -20,4 +20,4 @@ kxd context list [flags] * [kxd context](kxd_context.md) - Kubeconfig context command -###### Auto generated by spf13/cobra on 22-May-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/kxd_context_switch.md b/docs/kxd_context_switch.md index f9668a9..8002c0a 100644 --- a/docs/kxd_context_switch.md +++ b/docs/kxd_context_switch.md @@ -20,4 +20,4 @@ kxd context switch [flags] * [kxd context](kxd_context.md) - Kubeconfig context command -###### Auto generated by spf13/cobra on 22-May-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/kxd_file.md b/docs/kxd_file.md index 6b679f2..9c6fb0b 100644 --- a/docs/kxd_file.md +++ b/docs/kxd_file.md @@ -19,4 +19,4 @@ This is the default file command. * [kxd file list](kxd_file_list.md) - List kubeconfigs * [kxd file switch](kxd_file_switch.md) - Switch kubeconfig -###### Auto generated by spf13/cobra on 22-May-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/kxd_file_current.md b/docs/kxd_file_current.md index 2100a59..80f066b 100644 --- a/docs/kxd_file_current.md +++ b/docs/kxd_file_current.md @@ -20,4 +20,4 @@ kxd file current [flags] * [kxd file](kxd_file.md) - Kubeconfig file command -###### Auto generated by spf13/cobra on 22-May-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/kxd_file_list.md b/docs/kxd_file_list.md index f10c066..5b9a6a2 100644 --- a/docs/kxd_file_list.md +++ b/docs/kxd_file_list.md @@ -20,4 +20,4 @@ kxd file list [flags] * [kxd file](kxd_file.md) - Kubeconfig file command -###### Auto generated by spf13/cobra on 22-May-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/kxd_file_switch.md b/docs/kxd_file_switch.md index 80290dc..ede013e 100644 --- a/docs/kxd_file_switch.md +++ b/docs/kxd_file_switch.md @@ -20,4 +20,4 @@ kxd file switch [flags] * [kxd file](kxd_file.md) - Kubeconfig file command -###### Auto generated by spf13/cobra on 22-May-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/kxd_init.md b/docs/kxd_init.md new file mode 100644 index 0000000..7b5c788 --- /dev/null +++ b/docs/kxd_init.md @@ -0,0 +1,37 @@ +## kxd init + +Print the shell integration for the given shell. + +### Synopsis + +Print the shell integration for the given shell: the kxd function, completion, and the hook that applies the kubeconfig selected in ~/.kxd to new shells. + +kxd has to run inside your shell to export KUBECONFIG, because a child process cannot change its parent's environment. Eval this from your rc file: + + bash/zsh: eval "$(kxd init zsh)" + fish: kxd init fish | source + PowerShell: kxd init powershell | Out-String | Invoke-Expression + +``` +kxd init [flags] +``` + +### Examples + +``` + eval "$(kxd init zsh)" + + # supported: bash, zsh, fish, powershell +``` + +### Options + +``` + -h, --help help for init +``` + +### SEE ALSO + +* [kxd](kxd.md) - kxd - switch between Kubeconfigs and contexts. + +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/kxd_namespace.md b/docs/kxd_namespace.md index d1da8e7..b96d312 100644 --- a/docs/kxd_namespace.md +++ b/docs/kxd_namespace.md @@ -19,4 +19,4 @@ This is the default namespace command. * [kxd namespace list](kxd_namespace_list.md) - List kubeconfig namespaces * [kxd namespace switch](kxd_namespace_switch.md) - Switch k8s namespaces -###### Auto generated by spf13/cobra on 22-May-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/kxd_namespace_current.md b/docs/kxd_namespace_current.md index 255e0c9..04cf88f 100644 --- a/docs/kxd_namespace_current.md +++ b/docs/kxd_namespace_current.md @@ -20,4 +20,4 @@ kxd namespace current [flags] * [kxd namespace](kxd_namespace.md) - Kubeconfig namespace command -###### Auto generated by spf13/cobra on 22-May-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/kxd_namespace_list.md b/docs/kxd_namespace_list.md index 813cd40..bebd680 100644 --- a/docs/kxd_namespace_list.md +++ b/docs/kxd_namespace_list.md @@ -20,4 +20,4 @@ kxd namespace list [flags] * [kxd namespace](kxd_namespace.md) - Kubeconfig namespace command -###### Auto generated by spf13/cobra on 22-May-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/kxd_namespace_switch.md b/docs/kxd_namespace_switch.md index 1e43445..9f20728 100644 --- a/docs/kxd_namespace_switch.md +++ b/docs/kxd_namespace_switch.md @@ -20,4 +20,4 @@ kxd namespace switch [flags] * [kxd namespace](kxd_namespace.md) - Kubeconfig namespace command -###### Auto generated by spf13/cobra on 22-May-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/kxd_shellenv.md b/docs/kxd_shellenv.md new file mode 100644 index 0000000..d3ba42d --- /dev/null +++ b/docs/kxd_shellenv.md @@ -0,0 +1,23 @@ +## kxd shellenv + +Print the shell code that applies ~/.kxd to the current shell. + +### Synopsis + +Print the export/unset statement for the selected kubeconfig. Used by the function that `kxd init` generates; you should not need to call it directly. Defaults to POSIX (bash/zsh) syntax. + +``` +kxd shellenv [shell] [flags] +``` + +### Options + +``` + -h, --help help for shellenv +``` + +### SEE ALSO + +* [kxd](kxd.md) - kxd - switch between Kubeconfigs and contexts. + +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/docs/kxd_version.md b/docs/kxd_version.md index 52edac1..456eda6 100644 --- a/docs/kxd_version.md +++ b/docs/kxd_version.md @@ -20,4 +20,4 @@ kxd version [flags] * [kxd](kxd.md) - kxd - switch between Kubeconfigs and contexts. -###### Auto generated by spf13/cobra on 22-May-2026 +###### Auto generated by spf13/cobra on 5-Aug-2026 diff --git a/go.sum b/go.sum index f7ef756..d5c580d 100644 --- a/go.sum +++ b/go.sum @@ -108,22 +108,10 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= -k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo= -k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= -k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg= k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w= k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg= -k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA= -k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8= -k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ= -k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4= k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM= k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE= -k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= -k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= -k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= -k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg= k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= @@ -136,8 +124,6 @@ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5E sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/structured-merge-diff/v6 v6.3.3 h1:u08YRbVUi59ri4YD6cg0UqNM4Dimn0sIl+wldcx5PYw= sigs.k8s.io/structured-merge-diff/v6 v6.3.3/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= diff --git a/scripts/_kxd b/scripts/_kxd deleted file mode 100755 index 2f701b3..0000000 --- a/scripts/_kxd +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -# Call kxd with any provided arguments -_kxd_prompt "$@" - -selected_config="$(cat $HOME/.kxd)" - -# Set or unset KUBECONFIG based on content of $HOME/.kxd -if [ -z "$selected_config" ]; then - unset KUBECONFIG -else - export KUBECONFIG="$HOME/.kube/$selected_config" -fi diff --git a/scripts/_kxd.ps1 b/scripts/_kxd.ps1 deleted file mode 100644 index 8bfd5a4..0000000 --- a/scripts/_kxd.ps1 +++ /dev/null @@ -1,29 +0,0 @@ -# PowerShell wrapper for kxd. Mirrors scripts/_kxd (the bash version). -# -# The Go binary (_kxd_prompt.exe) writes the user's selection to ~/.kxd. This -# wrapper then reads that file and sets $env:KUBECONFIG in the current -# PowerShell session — the same `source`-the-script trick the bash version -# uses, since a child process cannot mutate its parent's environment. -# -# Setup: dot-source this file from your PowerShell profile ($PROFILE): -# . "$HOME\bin\_kxd.ps1" -# Then invoke as `kxd ...` like the POSIX version. - -function kxd { - & _kxd_prompt.exe @args - - $kxdFile = Join-Path $HOME '.kxd' - if (-not (Test-Path $kxdFile)) { - Remove-Item Env:KUBECONFIG -ErrorAction SilentlyContinue - return - } - - $selectedConfig = (Get-Content $kxdFile -Raw -ErrorAction SilentlyContinue) - if ($null -ne $selectedConfig) { $selectedConfig = $selectedConfig.Trim() } - - if ([string]::IsNullOrEmpty($selectedConfig)) { - Remove-Item Env:KUBECONFIG -ErrorAction SilentlyContinue - } else { - $env:KUBECONFIG = Join-Path $HOME ".kube\$selectedConfig" - } -} diff --git a/scripts/_kxd_autocomplete b/scripts/_kxd_autocomplete deleted file mode 100755 index 247c4d9..0000000 --- a/scripts/_kxd_autocomplete +++ /dev/null @@ -1,9 +0,0 @@ - -[ "$BASH_VERSION" ] && KXD_CMD="kxd" || KXD_CMD="_kxd" -_kxd_completion() { - local cur=${COMP_WORDS[COMP_CWORD]} - local suggestions=$(kxd file list) - COMPREPLY=($(compgen -W "$suggestions" -- $cur)) - return 0 -} -complete -o nospace -F _kxd_completion "${KXD_CMD}" diff --git a/scripts/_kxd_autocomplete.ps1 b/scripts/_kxd_autocomplete.ps1 deleted file mode 100644 index 30caed7..0000000 --- a/scripts/_kxd_autocomplete.ps1 +++ /dev/null @@ -1,18 +0,0 @@ -# PowerShell argument completer for kxd. Mirrors scripts/_kxd_autocomplete. -# -# Setup: dot-source from your PowerShell profile ($PROFILE), after _kxd.ps1: -# . "$HOME\bin\_kxd.ps1" -# . "$HOME\bin\_kxd_autocomplete.ps1" -# -# Completes the first positional arg (a config filename) from `_kxd_prompt -# file list`. Calls the binary directly to avoid the wrapper's KUBECONFIG -# side effects during tab-completion. - -Register-ArgumentCompleter -CommandName kxd -ScriptBlock { - param($wordToComplete, $commandAst, $cursorPosition) - & _kxd_prompt.exe file list 2>$null | - Where-Object { $_ -like "$wordToComplete*" } | - ForEach-Object { - [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) - } -} diff --git a/src/cmd/init.go b/src/cmd/init.go new file mode 100644 index 0000000..946c0d5 --- /dev/null +++ b/src/cmd/init.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "log" + "strings" + + "github.com/radiusmethod/kxd/src/utils" + "github.com/spf13/cobra" +) + +var initCmd = &cobra.Command{ + Use: "init ", + Short: "Print the shell integration for the given shell.", + Long: "Print the shell integration for the given shell: the kxd function, completion, " + + "and the hook that applies the kubeconfig selected in ~/.kxd to new shells.\n\n" + + "kxd has to run inside your shell to export KUBECONFIG, because a child process " + + "cannot change its parent's environment. Eval this from your rc file:\n\n" + + " bash/zsh: eval \"$(kxd init zsh)\"\n" + + " fish: kxd init fish | source\n" + + " PowerShell: kxd init powershell | Out-String | Invoke-Expression", + ValidArgs: utils.AcceptedShells, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + Run: func(cmd *cobra.Command, args []string) { + shell, err := utils.ParseShell(args[0]) + if err != nil { + log.Fatal(err) + } + fmt.Print(utils.InitScript(shell)) + }, +} + +var shellenvCmd = &cobra.Command{ + Use: "shellenv [shell]", + Short: "Print the shell code that applies ~/.kxd to the current shell.", + Long: "Print the export/unset statement for the selected kubeconfig. " + + "Used by the function that `kxd init` generates; you should not need to call it directly. " + + "Defaults to POSIX (bash/zsh) syntax.", + ValidArgs: utils.AcceptedShells, + Args: cobra.MatchAll(cobra.MaximumNArgs(1), cobra.OnlyValidArgs), + Run: func(cmd *cobra.Command, args []string) { + shell := utils.Bash + if len(args) == 1 { + s, err := utils.ParseShell(args[0]) + if err != nil { + log.Fatal(err) + } + shell = s + } + homeDir := utils.GetHomeDir() + config, err := utils.ReadState(homeDir) + if err != nil { + log.Fatal(err) + } + fmt.Print(utils.ShellEnv(config, homeDir, shell)) + }, +} + +func init() { + initCmd.Example = fmt.Sprintf(" eval \"$(kxd init zsh)\"\n\n # supported: %s", strings.Join(utils.SupportedShells, ", ")) + rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(shellenvCmd) +} diff --git a/src/cmd/root.go b/src/cmd/root.go index 34921c1..76ce301 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -1,13 +1,20 @@ package cmd import ( + "errors" "fmt" - "github.com/radiusmethod/kxd/src/utils" - "github.com/spf13/cobra" "log" "os" + + "github.com/radiusmethod/kxd/src/utils" + "github.com/spf13/cobra" ) +// errConfigNotFound is returned when argv named a kubeconfig that isn't in +// ~/.kube. The generated shell function keys off the exit code, so this has to +// fail rather than exit 0. +var errConfigNotFound = errors.New("config does not exist") + var rootCmd = &cobra.Command{ Use: "kxd", Short: "kxd - switch between Kubeconfigs and contexts.", @@ -28,9 +35,7 @@ func RootCmd() *cobra.Command { func Execute() { if shouldRunDirectConfigSwitch() { config := os.Args[1] - if err := directConfigSwitch(config); err != nil { - log.Fatal(err) - } + handleSwitchError(directConfigSwitch(config)) return } runRootCmd() @@ -49,11 +54,26 @@ func directConfigSwitch(desiredConfig string) error { return nil } - fmt.Printf(utils.NoticeColor, "WARNING: Config ") - fmt.Printf(utils.CyanColor, desiredConfig) - fmt.Printf(utils.NoticeColor, " does not exist or is invalid.\n") + // stderr, not stdout: the shell integration evals command substitutions of + // this binary, so a warning on stdout would be executed instead of shown. + fmt.Fprintf(os.Stderr, utils.NoticeColor, "WARNING: Config ") + fmt.Fprintf(os.Stderr, utils.CyanColor, desiredConfig) + fmt.Fprintf(os.Stderr, utils.NoticeColor, " does not exist or is invalid.\n") + + return fmt.Errorf("%w: %s", errConfigNotFound, desiredConfig) +} - return nil +// handleSwitchError exits non-zero when a config switch fails. A missing config +// has already been reported on stderr, so exit quietly instead of printing it a +// second time through log.Fatal. +func handleSwitchError(err error) { + if err == nil { + return + } + if errors.Is(err, errConfigNotFound) { + os.Exit(1) + } + log.Fatal(err) } func runRootCmd() { @@ -63,6 +83,8 @@ func runRootCmd() { } func shouldRunDirectConfigSwitch() bool { - invalidConfigs := []string{"f", "file", "ctx", "context", "ns", "namespace", "completion", "help", "--help", "v", "version"} + // Any argv[1] not in this list is treated as a kubeconfig name, so every + // subcommand and alias has to be listed here. + invalidConfigs := []string{"f", "file", "ctx", "context", "ns", "namespace", "init", "shellenv", "completion", "help", "--help", "v", "version"} return len(os.Args) > 1 && !utils.Contains(invalidConfigs, os.Args[1]) } diff --git a/src/cmd/root_test.go b/src/cmd/root_test.go index 039c3ac..f503f5c 100644 --- a/src/cmd/root_test.go +++ b/src/cmd/root_test.go @@ -52,15 +52,18 @@ func TestDirectConfigSwitch(t *testing.T) { } tests := []struct { - name string - desired string - expectFile bool - expectedKxd string + name string + desired string + expectFile bool + expectedKxd string + expectError bool }{ - {"valid config", "dev.conf", true, "dev.conf"}, + {"valid config", "dev.conf", true, "dev.conf", false}, // "default" is rewritten to "config" before being persisted. - {"default sentinel rewrites to config", "default", true, "config"}, - {"invalid config leaves .kxd untouched", "nope.conf", false, ""}, + {"default sentinel rewrites to config", "default", true, "config", false}, + // An unknown config must fail rather than exit 0: the generated shell + // function keys off the exit code before applying any new state. + {"invalid config leaves .kxd untouched", "nope.conf", false, "", true}, } for _, tt := range tests { @@ -69,7 +72,11 @@ func TestDirectConfigSwitch(t *testing.T) { _ = os.Remove(kxdFile) err := directConfigSwitch(tt.desired) - assert.NoError(t, err) + if tt.expectError { + assert.ErrorIs(t, err, errConfigNotFound) + } else { + assert.NoError(t, err) + } if tt.expectFile { content, err := os.ReadFile(kxdFile) diff --git a/src/cmd/version.go b/src/cmd/version.go index d86078c..d1fd15d 100644 --- a/src/cmd/version.go +++ b/src/cmd/version.go @@ -5,7 +5,13 @@ import ( "github.com/spf13/cobra" ) -var version string = "v0.1.4" +// version is injected at build time via ldflags: +// +// -X github.com/radiusmethod/kxd/src/cmd.version=v1.2.3 +// +// GoReleaser sets it from the git tag, and `make install` sets it from +// `git describe`. Plain `go build` leaves it as "dev". +var version = "dev" var versionCmd = &cobra.Command{ Use: "version", diff --git a/src/utils/shell.go b/src/utils/shell.go new file mode 100644 index 0000000..30070c1 --- /dev/null +++ b/src/utils/shell.go @@ -0,0 +1,255 @@ +package utils + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Shell identifies a supported shell dialect for `kxd init` / `kxd shellenv`. +type Shell string + +const ( + Bash Shell = "bash" + Zsh Shell = "zsh" + Fish Shell = "fish" + PowerShell Shell = "powershell" +) + +// SupportedShells is the canonical list, in help/doc order. +var SupportedShells = []string{"bash", "zsh", "fish", "powershell"} + +// AcceptedShells is everything ParseShell takes, including aliases. Used for +// argument validation and completion. +var AcceptedShells = append(append([]string{}, SupportedShells...), "pwsh") + +// ParseShell maps a user-supplied shell name to a Shell. +func ParseShell(name string) (Shell, error) { + switch strings.ToLower(strings.TrimSpace(name)) { + case "bash": + return Bash, nil + case "zsh": + return Zsh, nil + case "fish": + return Fish, nil + case "powershell", "pwsh": + return PowerShell, nil + } + return "", fmt.Errorf("unsupported shell %q (supported: %s)", name, strings.Join(SupportedShells, ", ")) +} + +// ReadState returns the raw contents of ~/.kxd: the selected kubeconfig +// filename, or "" meaning KUBECONFIG should be unset. A missing file reads as +// empty rather than an error, so a fresh install still produces valid output. +func ReadState(loc string) (string, error) { + data, err := os.ReadFile(filepath.Join(loc, ".kxd")) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +// quote wraps a value in single quotes, escaped for the given shell. Kubeconfig +// filenames come off the filesystem, so they can contain spaces and quotes. +func quote(sh Shell, value string) string { + switch sh { + case Fish: + // Inside fish single quotes only \ and ' are special. + r := strings.NewReplacer(`\`, `\\`, `'`, `\'`) + return "'" + r.Replace(value) + "'" + case PowerShell: + // Inside PowerShell single quotes only ' is special, doubled to escape. + return "'" + strings.ReplaceAll(value, "'", "''") + "'" + default: + // POSIX: end the quoted run, emit an escaped quote, reopen. + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" + } +} + +// ShellEnv renders the KUBECONFIG mutation for the given ~/.kxd value as shell +// code, ready to be eval'd by the function that `kxd init` generates. This is +// the only place the ~/.kxd contract becomes an env change; the wrapper scripts +// used to duplicate it per shell, including the ~/.kube path join. +// +// - empty value -> unset KUBECONFIG +// - "config" -> the default ~/.kube/config +// - anything else -> ~/.kube/ +func ShellEnv(config, homeDir string, sh Shell) string { + if config == "" { + switch sh { + case Fish: + return "set -e KUBECONFIG\n" + case PowerShell: + return "$env:KUBECONFIG = $null\n" + default: + return "unset KUBECONFIG\n" + } + } + + path := filepath.Join(homeDir, ".kube", config) + switch sh { + case Fish: + return fmt.Sprintf("set -gx KUBECONFIG %s\n", quote(sh, path)) + case PowerShell: + return fmt.Sprintf("$env:KUBECONFIG = %s\n", quote(sh, path)) + default: + return fmt.Sprintf("export KUBECONFIG=%s\n", quote(sh, path)) + } +} + +// InitScript returns the shell integration to be eval'd from a shell rc file. +// It defines the kxd function, wires up completion, and applies the selection +// already in ~/.kxd so a new shell starts on the right kubeconfig. +func InitScript(sh Shell) string { + switch sh { + case Bash: + return posixInit(Bash, "") + case Zsh: + return posixInit(Zsh, zshCompinit) + case Fish: + return fishInit + case PowerShell: + return powershellInit + } + return "" +} + +// zsh needs bashcompinit to get the bash-style `complete` builtin, and +// bashcompinit needs compinit to have run first for compdef to exist. Most +// setups (oh-my-zsh and friends) have already run it, so only pay for it here +// if nothing else did. +const zshCompinit = `if (( ! $+functions[compdef] )); then + autoload -Uz compinit && compinit +fi +autoload -U +X bashcompinit && bashcompinit +` + +// posixInit covers bash and zsh, which differ only in needing bashcompinit to +// provide the bash-style `complete` builtin. +func posixInit(sh Shell, compinit string) string { + return fmt.Sprintf(posixInitTemplate, string(sh), compinit) +} + +// Namespace completion is deliberately absent everywhere: `kxd namespace list` +// calls the live cluster, and hanging the shell for up to 10s on a TAB press is +// worse than no completion. +const posixInitTemplate = `# kxd shell integration (%[1]s). Generated by: kxd init %[1]s +# Add to your rc file with: eval "$(kxd init %[1]s)" + +kxd() { + command kxd "$@" || return + eval "$(command kxd shellenv %[1]s)" +} + +_kxd_completion() { + local cur=${COMP_WORDS[COMP_CWORD]} + local prev=${COMP_WORDS[COMP_CWORD-1]} + local subcmd=${COMP_WORDS[1]} + local suggestions + + case "$COMP_CWORD" in + 1) + suggestions="$(command kxd file list) file context namespace" + ;; + 2) + case "$subcmd" in + f|file|ctx|context|ns|namespace) suggestions="switch current list" ;; + *) suggestions="" ;; + esac + ;; + 3) + case "$prev" in + s|switch) + case "$subcmd" in + f|file) suggestions="$(command kxd file list)" ;; + ctx|context) suggestions="$(command kxd context list)" ;; + *) suggestions="" ;; + esac + ;; + *) suggestions="" ;; + esac + ;; + *) + suggestions="" + ;; + esac + + COMPREPLY=($(compgen -W "$suggestions" -- "$cur")) + return 0 +} + +%[2]scomplete -o nospace -F _kxd_completion kxd + +# Apply the kubeconfig already selected in ~/.kxd to this shell. +eval "$(command kxd shellenv %[1]s)" +` + +const fishInit = `# kxd shell integration (fish). Generated by: kxd init fish +# Add to ~/.config/fish/config.fish with: kxd init fish | source + +function kxd + command kxd $argv; or return + command kxd shellenv fish | source +end + +complete -c kxd -f +complete -c kxd -n __fish_use_subcommand -a '(command kxd file list)' +complete -c kxd -n __fish_use_subcommand -a 'file context namespace' +complete -c kxd -n '__fish_seen_subcommand_from f file ctx context ns namespace' -a 'switch current list' +complete -c kxd -n '__fish_seen_subcommand_from f file; and __fish_seen_subcommand_from s switch' -a '(command kxd file list)' +complete -c kxd -n '__fish_seen_subcommand_from ctx context; and __fish_seen_subcommand_from s switch' -a '(command kxd context list)' + +# Apply the kubeconfig already selected in ~/.kxd to this shell. +command kxd shellenv fish | source +` + +const powershellInit = `# kxd shell integration (PowerShell). Generated by: kxd init powershell +# Add to your $PROFILE with: kxd init powershell | Out-String | Invoke-Expression + +# Resolve the binary once, before the function below shadows the name. Unlike +# POSIX "command", PowerShell's & operator does not skip functions, so calling +# "& kxd" inside function kxd would recurse forever. +$global:KxdBin = (Get-Command -CommandType Application kxd -ErrorAction SilentlyContinue | + Select-Object -First 1).Source +if (-not $global:KxdBin) { + Write-Error "kxd: could not find the kxd binary on PATH" + return +} + +function kxd { + param([Parameter(ValueFromRemainingArguments = $true)] [string[]] $Arguments) + & $global:KxdBin @Arguments + if ($LASTEXITCODE -ne 0) { return } + & $global:KxdBin shellenv powershell | Out-String | Invoke-Expression +} + +Register-ArgumentCompleter -CommandName kxd -ParameterName Arguments -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + + $words = @($commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object { $_.ToString() }) + if ($wordToComplete) { $words = @($words | Select-Object -SkipLast 1) } + + $suggestions = @() + if ($words.Count -eq 0) { + $suggestions = @(& $global:KxdBin file list) + @('file', 'context', 'namespace') + } elseif ($words.Count -eq 1) { + if ($words[0] -in @('f', 'file', 'ctx', 'context', 'ns', 'namespace')) { + $suggestions = @('switch', 'current', 'list') + } + } elseif ($words.Count -eq 2 -and $words[1] -in @('s', 'switch')) { + if ($words[0] -in @('f', 'file')) { $suggestions = @(& $global:KxdBin file list) } + elseif ($words[0] -in @('ctx', 'context')) { $suggestions = @(& $global:KxdBin context list) } + } + + $suggestions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } +} + +# Apply the kubeconfig already selected in ~/.kxd to this shell. +& $global:KxdBin shellenv powershell | Out-String | Invoke-Expression +` diff --git a/src/utils/shell_test.go b/src/utils/shell_test.go new file mode 100644 index 0000000..29a2bc5 --- /dev/null +++ b/src/utils/shell_test.go @@ -0,0 +1,247 @@ +package utils + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseShell(t *testing.T) { + tests := []struct { + name string + input string + expected Shell + expectError bool + }{ + {name: "bash", input: "bash", expected: Bash}, + {name: "zsh", input: "zsh", expected: Zsh}, + {name: "fish", input: "fish", expected: Fish}, + {name: "powershell", input: "powershell", expected: PowerShell}, + {name: "pwsh alias", input: "pwsh", expected: PowerShell}, + {name: "case insensitive", input: "PowerShell", expected: PowerShell}, + {name: "surrounding space", input: " zsh ", expected: Zsh}, + {name: "unknown shell", input: "csh", expectError: true}, + {name: "empty", input: "", expectError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + shell, err := ParseShell(tt.input) + if tt.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.expected, shell) + }) + } +} + +func TestReadState(t *testing.T) { + tests := []struct { + name string + contents string + write bool + expected string + }{ + {name: "Config name", contents: "dev.conf\n", write: true, expected: "dev.conf"}, + {name: "Default config", contents: "config\n", write: true, expected: "config"}, + {name: "Empty means unset", contents: "", write: true, expected: ""}, + {name: "Trailing whitespace trimmed", contents: " dev.conf \n", write: true, expected: "dev.conf"}, + {name: "Missing file reads as empty", write: false, expected: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if tt.write { + if err := os.WriteFile(filepath.Join(dir, ".kxd"), []byte(tt.contents), 0644); err != nil { + t.Fatalf("Failed to write .kxd: %v", err) + } + } + got, err := ReadState(dir) + assert.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestShellEnv(t *testing.T) { + home := "/home/tester" + + tests := []struct { + name string + config string + shell Shell + expected string + }{ + { + name: "Named config, posix", + config: "dev.conf", + shell: Bash, + expected: "export KUBECONFIG='/home/tester/.kube/dev.conf'\n", + }, + { + name: "Default config maps to ~/.kube/config", + config: "config", + shell: Zsh, + expected: "export KUBECONFIG='/home/tester/.kube/config'\n", + }, + { + name: "Empty unsets, posix", + config: "", + shell: Bash, + expected: "unset KUBECONFIG\n", + }, + { + name: "Named config, fish", + config: "dev.conf", + shell: Fish, + expected: "set -gx KUBECONFIG '/home/tester/.kube/dev.conf'\n", + }, + { + name: "Empty unsets, fish", + config: "", + shell: Fish, + expected: "set -e KUBECONFIG\n", + }, + { + name: "Named config, powershell", + config: "dev.conf", + shell: PowerShell, + expected: "$env:KUBECONFIG = '/home/tester/.kube/dev.conf'\n", + }, + { + name: "Empty unsets, powershell", + config: "", + shell: PowerShell, + expected: "$env:KUBECONFIG = $null\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, ShellEnv(tt.config, home, tt.shell)) + }) + } +} + +// Kubeconfig filenames come off the filesystem, so they can contain characters +// the shell would otherwise interpret. Unquoted output would run as code. +func TestShellEnvQuoting(t *testing.T) { + home := "/home/tester" + + tests := []struct { + name string + shell Shell + config string + expected string + }{ + { + name: "Space, posix", + shell: Bash, + config: "my cluster.conf", + expected: "export KUBECONFIG='/home/tester/.kube/my cluster.conf'\n", + }, + { + name: "Single quote, posix", + shell: Bash, + config: "we'ird.conf", + expected: `export KUBECONFIG='/home/tester/.kube/we'\''ird.conf'` + "\n", + }, + { + name: "Command substitution stays literal, posix", + shell: Bash, + config: "$(whoami).conf", + expected: "export KUBECONFIG='/home/tester/.kube/$(whoami).conf'\n", + }, + { + name: "Single quote, fish", + shell: Fish, + config: "we'ird.conf", + expected: `set -gx KUBECONFIG '/home/tester/.kube/we\'ird.conf'` + "\n", + }, + { + name: "Single quote, powershell", + shell: PowerShell, + config: "we'ird.conf", + expected: "$env:KUBECONFIG = '/home/tester/.kube/we''ird.conf'\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, ShellEnv(tt.config, home, tt.shell)) + }) + } +} + +func TestInitScript(t *testing.T) { + tests := []struct { + name string + shell Shell + contains []string + }{ + { + name: "bash", + shell: Bash, + contains: []string{"kxd() {", `eval "$(command kxd shellenv bash)"`, "complete -o nospace -F _kxd_completion kxd"}, + }, + { + name: "zsh", + shell: Zsh, + contains: []string{"kxd() {", `eval "$(command kxd shellenv zsh)"`, "bashcompinit"}, + }, + { + name: "fish", + shell: Fish, + contains: []string{"function kxd", "command kxd shellenv fish | source", "complete -c kxd"}, + }, + { + name: "powershell", + shell: PowerShell, + contains: []string{"function kxd", "& $global:KxdBin shellenv powershell | Out-String | Invoke-Expression", "Register-ArgumentCompleter"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + script := InitScript(tt.shell) + assert.NotEmpty(t, script) + for _, want := range tt.contains { + assert.Contains(t, script, want) + } + }) + } +} + +// `kxd namespace list` calls the live cluster, so wiring it into completion +// would hang the shell on a TAB press. +func TestInitScriptDoesNotCompleteNamespaces(t *testing.T) { + for _, shell := range []Shell{Bash, Zsh, Fish, PowerShell} { + assert.NotContains(t, InitScript(shell), "namespace list", "%s must not shell out to namespace list", shell) + } +} + +// bashcompinit only provides `complete` for zsh; bash must not carry it. +func TestInitScriptBashHasNoCompinit(t *testing.T) { + assert.NotContains(t, InitScript(Bash), "bashcompinit") +} + +func TestInitScriptUnknownShell(t *testing.T) { + assert.Empty(t, InitScript(Shell("csh"))) +} + +// Every accepted shell has to produce a script, or `kxd init ` would +// validate the argument and then print nothing. +func TestInitScriptCoversAcceptedShells(t *testing.T) { + for _, name := range AcceptedShells { + shell, err := ParseShell(name) + assert.NoError(t, err) + assert.NotEmpty(t, InitScript(shell), "no init script for %s", name) + assert.True(t, strings.HasSuffix(InitScript(shell), "\n"), "%s script must end in a newline", name) + } +} diff --git a/tools/gendocs/go.mod b/tools/gendocs/go.mod index 222923b..5d25010 100644 --- a/tools/gendocs/go.mod +++ b/tools/gendocs/go.mod @@ -44,14 +44,14 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.36.1 // indirect - k8s.io/apimachinery v0.36.1 // indirect - k8s.io/client-go v0.36.1 // indirect + k8s.io/api v0.36.3 // indirect + k8s.io/apimachinery v0.36.3 // indirect + k8s.io/client-go v0.36.3 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/tools/gendocs/go.sum b/tools/gendocs/go.sum index fa8bd1a..ba1b06c 100644 --- a/tools/gendocs/go.sum +++ b/tools/gendocs/go.sum @@ -110,12 +110,12 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= -k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo= -k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA= -k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8= -k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= -k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= +k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w= +k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg= +k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM= +k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE= +k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg= +k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= @@ -126,7 +126,7 @@ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5E sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.3 h1:u08YRbVUi59ri4YD6cg0UqNM4Dimn0sIl+wldcx5PYw= +sigs.k8s.io/structured-merge-diff/v6 v6.3.3/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=