diff --git a/Justfile b/Justfile index 39988cf..c5f9a9a 100644 --- a/Justfile +++ b/Justfile @@ -149,21 +149,21 @@ brew-formula tag sums="dist/checksums.txt": # Smoke-test install.sh and uninstall.sh against the published releases, in a # throwaway directory with a throwaway HOME — never touches /usr/local/bin or -# your real ~/.k10s. Both piped paths are exercised too, since no-TTY is what +# your real config directory. Both piped paths are exercised too, since no-TTY is what # real users hit (no TTY → no sudo prompt, no confirmation prompt). test-install dir="/tmp/k10s-install-test": #!/usr/bin/env bash set -euo pipefail - rm -rf {{dir}}; mkdir -p {{dir}}/bin {{dir}}/home/.k10s + rm -rf {{dir}}; mkdir -p {{dir}}/bin {{dir}}/home/.config/k10s sh install.sh --dir {{dir}}/bin {{dir}}/bin/{{binary}} --version cat install.sh | sh -s -- --dir {{dir}}/bin - echo "theme: dracula" > {{dir}}/home/.k10s/config.yaml - HOME={{dir}}/home PATH="{{dir}}/bin:$PATH" sh uninstall.sh --dry-run - HOME={{dir}}/home PATH="{{dir}}/bin:$PATH" sh uninstall.sh -y --purge + echo "theme: dracula" > {{dir}}/home/.config/k10s/config.yaml + HOME={{dir}}/home XDG_CONFIG_HOME= PATH="{{dir}}/bin:$PATH" sh uninstall.sh --dry-run + HOME={{dir}}/home XDG_CONFIG_HOME= PATH="{{dir}}/bin:$PATH" sh uninstall.sh -y --purge [ ! -e {{dir}}/bin/{{binary}} ] || { echo "binary survived uninstall"; exit 1; } - [ ! -e {{dir}}/home/.k10s ] || { echo "--purge left the config behind"; exit 1; } - HOME={{dir}}/home sh uninstall.sh --dir {{dir}}/bin # nothing left to remove + [ ! -e {{dir}}/home/.config/k10s ] || { echo "--purge left the config behind"; exit 1; } + HOME={{dir}}/home XDG_CONFIG_HOME= sh uninstall.sh --dir {{dir}}/bin # nothing left to remove rm -rf {{dir}} # Tag this commit and push it; the release workflow publishes the build. diff --git a/README.md b/README.md index 31213a8..51ec037 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ and **how little you have to remember**. running binary - checksum-verified, atomic, offers to restart into it. On a Homebrew install it points at `brew upgrade k10s` instead. - **Your k9s-style command plugins fit.** Put scoped shortcuts in - `~/.k10s/plugins.yaml`; they appear beside built-in actions and receive the + `~/.config/k10s/plugins.yaml`; they appear beside built-in actions and receive the selected object, namespace, context and column values. ## Try it in 30 seconds (no cluster required) @@ -230,7 +230,7 @@ from this disabled integration. `T` cycles, `/theme` opens a picker that **applies each theme as you move through the list** - you judge it on the real UI, not on a name - and `esc` -puts back whatever you had. Drop a YAML palette into `~/.k10s/themes`, restart, +puts back whatever you had. Drop a YAML palette into `~/.config/k10s/themes`, restart, and it appears in the same picker — no rebuild needed. See the [custom-theme guide and installable demo](docs/themes.md). @@ -334,7 +334,7 @@ talk to. `:ctx ` switches for the session without touching kubeconfig; `kubectl config use-context` is what changes where it opens next time. Theme, namespace, folded sidebar groups, AI settings and the update check -persist to `~/.k10s/config.yaml` (override with `K10S_CONFIG`), saved on +persist to `~/.config/k10s/config.yaml` (`$XDG_CONFIG_HOME/k10s` when set; override with `K10S_CONFIG`), saved on every change. See [config.md](docs/config.md). diff --git a/SECURITY.md b/SECURITY.md index ed90611..667b4ac 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -71,11 +71,11 @@ install anything else, or to replace any other file, is a vulnerability. The checksum shows that the archive is the one the release lists, not who published the release: anyone who can publish a release in that repository can ship a binary that passes, which is what the attestations below are -for. `update.disabled: true` in `~/.k10s/config.yaml` turns the daily check +for. `update.disabled: true` in `~/.config/k10s/config.yaml` turns the daily check off. See [docs/update.md](docs/update.md). **Plugins and shell commands run as you.** A plugin file is a program you -chose to run, so anyone who can write to `~/.k10s/plugins.yaml` can already +chose to run, so anyone who can write to `~/.config/k10s/plugins.yaml` can already run commands as you; that is not a vulnerability in k10s. k10s runs the plugin's command directly, never through a shell of its own, and fills object names, namespaces and column values into its arguments as text. When @@ -104,7 +104,7 @@ Known limits, documented and not vulnerabilities in themselves: when the release's `checksums.txt` cannot be fetched or does not list the archive, or on a machine with no `sha256sum`, `shasum` or `openssl`. The self-updater refuses instead. -- `~/.k10s/config.yaml` (mode 0600) can hold the legacy AI API key in plain +- `~/.config/k10s/config.yaml` (mode 0600) can hold the legacy AI API key in plain text. AI is disabled in this build. ## Checking a release diff --git a/cmd/shot/main.go b/cmd/shot/main.go index 097ebd0..87bd92e 100644 --- a/cmd/shot/main.go +++ b/cmd/shot/main.go @@ -32,7 +32,7 @@ var special = map[string]tea.KeyType{ } func main() { - // Never touch the user's real ~/.k10s from the dev renderer, and never + // Never touch the user's real k10s config directory from the dev renderer, and never // share state between invocations either — each run gets its own throwaway // path unless the caller sets K10S_CONFIG explicitly (e.g. to test // persistence across two shot calls on purpose). diff --git a/docs/README.md b/docs/README.md index 9a892c0..88c58ec 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ | [commands.md](commands.md) | Prompt modes, slash commands, search palette, AI settings | | [themes.md](themes.md) | Theme list, picker, palette contract, adding a theme | | [plugins.md](plugins.md) | k9s-compatible command plugins, variables, examples | -| [config.md](config.md) | `~/.k10s/config.yaml` — what's saved, when, and how | +| [config.md](config.md) | `config.yaml` — what's saved, when, and how | | [install.md](install.md) | Installer script, flags, hosting the short URL, uninstall | | [update.md](update.md) | Self-update: version stamps, release matching, installing | | [dev.md](dev.md) | Build, tests, headless renderer, width invariant | diff --git a/docs/architecture.md b/docs/architecture.md index d6194de..4fa72e9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,7 +12,7 @@ internal/mock/ offline demo backend, same interface, no network and the tests — never as a fallback) internal/ai/ OpenAI-compatible / Anthropic HTTP calls internal/theme/ Theme struct + 7 palettes -internal/config/ ~/.k10s/config.yaml load/save +internal/config/ config.yaml load/save (XDG config dir) internal/version/ the -ldflags build stamp (version, commit, date) internal/update/ self-update: GitHub releases, checksums, atomic swap internal/ui/ diff --git a/docs/config.md b/docs/config.md index 3522c7d..4ce7f07 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,16 +1,22 @@ # Config file -`internal/config` persists settings to `~/.k10s/config.yaml` (override with -`K10S_CONFIG=/path/to/file`, used by `cmd/shot` in tests so it never touches -a real user's file). +`internal/config` persists settings to `$XDG_CONFIG_HOME/k10s/config.yaml` +(`~/.config/k10s/config.yaml` when `XDG_CONFIG_HOME` is unset or not an +absolute path). Override with `K10S_CONFIG=/path/to/file`, used by `cmd/shot` +in tests so it never touches a real user's file. + +An existing `~/.k10s` directory keeps being used as long as the XDG directory +does not exist, so upgrading loses nothing. To migrate, move it **before** +creating anything under the XDG path (creating `~/.config/k10s` switches k10s +over to it): `mv ~/.k10s ~/.config/k10s`. Custom themes live in the adjacent `themes/` folder (normally -`~/.k10s/themes`) and can be moved independently with `K10S_THEME_DIR`. Theme +`~/.config/k10s/themes`) and can be moved independently with `K10S_THEME_DIR`. Theme files are documented in [themes.md](themes.md); only the selected theme name is stored in `config.yaml`. -Command plugins are separate too: `~/.k10s/plugins.yaml` and YAML snippets -under `~/.k10s/plugins/`. They use the k9s plugin format and are documented in +Command plugins are separate too: `plugins.yaml` and YAML snippets under +`plugins/`, both beside `config.yaml`. They use the k9s plugin format and are documented in [plugins.md](plugins.md); plugin definitions are never rewritten by k10s. ## What's saved @@ -47,7 +53,7 @@ in a YAML library isn't worth it. Values round-trip through Go string quoting on nothing fancier (lists, multi-line, anchors) is needed or supported. File permissions are `0600` since the file can hold an API key; the parent -directory `~/.k10s` is created with `0755` on first save. +directory is created with `0755` on first save. `collapsed` lists the Resources-pane groups folded away. It is written on every save, and **`"-"` means "none, deliberately"** — an empty value would diff --git a/docs/install.md b/docs/install.md index af7fe06..b2563e5 100644 --- a/docs/install.md +++ b/docs/install.md @@ -222,7 +222,7 @@ prompts without a TTY. | Flag | Environment | Effect | |-------------------|--------------------|-------------------------------------------------------| -| `--purge` | `K10S_PURGE=1` | also delete `~/.k10s` | +| `--purge` | `K10S_PURGE=1` | also delete the config directory | | `--keep-config` | — | keep it, and drop the reminder that it is still there | | `--dir ` | `K10S_INSTALL_DIR` | look only there | | `--dry-run`, `-n` | — | list, remove nothing | @@ -233,5 +233,5 @@ By hand, it is two lines: ```bash rm "$(command -v k10s)" -rm -rf ~/.k10s # config + theme choice, see config.md +rm -rf ~/.config/k10s # config + theme choice (~/.k10s on older installs), see config.md ``` diff --git a/docs/keybindings.md b/docs/keybindings.md index 02c74fc..cd85cbc 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -140,7 +140,7 @@ Everything else still works: the sidebar, the prompt, `/help`, the theme picker. See [cluster-setup.md](cluster-setup.md) and [ui.md](ui.md#no-cluster). -Applicable command plugins from `~/.k10s/plugins.yaml` are listed after the +Applicable command plugins from `~/.config/k10s/plugins.yaml` are listed after the built-in actions. Their configured shortcut or a click runs them. Built-in keys win unless the plugin sets `override: true`; see [plugins.md](plugins.md). diff --git a/docs/plugins.md b/docs/plugins.md index 9c14522..0c6a5eb 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -8,8 +8,8 @@ Applicable plugins appear in the Actions pane and can also be clicked. k10s loads these at startup, in order: -1. `~/.k10s/plugins.yaml` -2. every `.yaml` or `.yml` file recursively under `~/.k10s/plugins/` +1. `~/.config/k10s/plugins.yaml` (`$XDG_CONFIG_HOME/k10s/` when set) +2. every `.yaml` or `.yml` file recursively under `~/.config/k10s/plugins/` Files in the directory override an earlier plugin with the same name. Use `K10S_PLUGINS` and `K10S_PLUGIN_DIR` to override the two locations. When diff --git a/docs/roadmap.md b/docs/roadmap.md index ff0a99e..225d5ed 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -67,7 +67,7 @@ undo by accident. (`update.DefaultRepo`), but this tree has no tags, so `/update` reports "no published releases" until `just tag v0.1.0` pushes one. A fork can point elsewhere with `K10S_UPDATE_REPO` / `update.repo`. -- **The API key is stored as plain text** in `~/.k10s/config.yaml` (mode +- **The API key is stored as plain text** in `~/.config/k10s/config.yaml` (mode 0600), masked only in the UI. Swapping in an OS keychain behind the same `AI.APIKey` field is the fix if that's not acceptable. - **Untested against a real cluster by its author.** Every live path is diff --git a/docs/themes.md b/docs/themes.md index f441dd5..ac85600 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -23,7 +23,7 @@ and `esc` restores whatever was active before you opened it. k10s loads custom `.yaml` and `.yml` files at startup and appends every valid theme to the normal theme picker and `T` / `ctrl+t` cycle. The default folder -is `~/.k10s/themes` (the `themes` folder beside `config.yaml`). If +is `~/.config/k10s/themes` (the `themes` folder beside `config.yaml`, so it follows `$XDG_CONFIG_HOME`). If `K10S_CONFIG` points elsewhere, the folder follows it; `K10S_THEME_DIR` overrides the theme folder directly. @@ -32,27 +32,27 @@ overrides the theme folder directly. From a cloned repository on macOS or Linux: ```bash -mkdir -p ~/.k10s/themes -install -m 0644 examples/themes/rose-pine.yaml ~/.k10s/themes/rose-pine.yaml +mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/k10s/themes" +install -m 0644 examples/themes/rose-pine.yaml "${XDG_CONFIG_HOME:-$HOME/.config}/k10s/themes/rose-pine.yaml" k10s ``` Without cloning: ```bash -mkdir -p ~/.k10s/themes +mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/k10s/themes" curl -fsSL https://raw.githubusercontent.com/p10node/k10s/main/examples/themes/rose-pine.yaml \ - -o ~/.k10s/themes/rose-pine.yaml + -o "${XDG_CONFIG_HOME:-$HOME/.config}/k10s/themes/rose-pine.yaml" k10s ``` On Windows PowerShell: ```powershell -New-Item -ItemType Directory -Force "$HOME/.k10s/themes" | Out-Null +New-Item -ItemType Directory -Force "$HOME/.config/k10s/themes" | Out-Null Invoke-WebRequest ` https://raw.githubusercontent.com/p10node/k10s/main/examples/themes/rose-pine.yaml ` - -OutFile "$HOME/.k10s/themes/rose-pine.yaml" + -OutFile "$HOME/.config/k10s/themes/rose-pine.yaml" k10s ``` diff --git a/docs/update.md b/docs/update.md index 6920749..d2c96a9 100644 --- a/docs/update.md +++ b/docs/update.md @@ -197,7 +197,7 @@ It skips when `dist/` is empty. Resolution order, most specific first: -1. `update.repo` in `~/.k10s/config.yaml` +1. `update.repo` in `~/.config/k10s/config.yaml` 2. `$K10S_UPDATE_REPO` 3. `update.DefaultRepo` — compiled in diff --git a/examples/themes/rose-pine.yaml b/examples/themes/rose-pine.yaml index 5308230..7d741a3 100644 --- a/examples/themes/rose-pine.yaml +++ b/examples/themes/rose-pine.yaml @@ -1,5 +1,5 @@ # Rose Pine — demo custom theme for k10s. -# Install: mkdir -p ~/.k10s/themes && cp examples/themes/rose-pine.yaml ~/.k10s/themes/ +# Install: mkdir -p ~/.config/k10s/themes && cp examples/themes/rose-pine.yaml ~/.config/k10s/themes/ name: rose-pine bg: "#191724" fg: "#e0def4" diff --git a/internal/config/config.go b/internal/config/config.go index e7e9f5f..64949e2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,4 +1,5 @@ -// Package config persists user settings to ~/.k10s/config.yaml. +// Package config persists user settings to config.yaml in the k10s config +// directory (see Path). package config import ( @@ -85,17 +86,46 @@ const DefaultCLI = "kubectl" // CLIPresets are the names k10s recognises out of the box. var CLIPresets = []string{"kubectl", "k8s", "k"} -// Path returns the config file location: $K10S_CONFIG if set, -// otherwise ~/.k10s/config.yaml. +// Path returns the config file location. First match wins: +// +// 1. $K10S_CONFIG +// 2. ~/.k10s/config.yaml, while ~/.k10s exists and the XDG directory does +// not yet (installs from before k10s followed XDG keep their settings) +// 3. $XDG_CONFIG_HOME/k10s/config.yaml, or ~/.config/k10s/config.yaml when +// XDG_CONFIG_HOME is unset or not an absolute path (per the XDG spec) +// +// Plugins and custom themes live beside this file, so they follow it. func Path() string { if p := os.Getenv("K10S_CONFIG"); p != "" { return p } - home, err := os.UserHomeDir() - if err != nil { + dir := configDir() + if dir == "" { return "" } - return filepath.Join(home, ".k10s", "config.yaml") + return filepath.Join(dir, "config.yaml") +} + +func configDir() string { + home, _ := os.UserHomeDir() + xdg := "" + if base := os.Getenv("XDG_CONFIG_HOME"); filepath.IsAbs(base) { + xdg = filepath.Join(base, "k10s") + } else if home != "" { + xdg = filepath.Join(home, ".config", "k10s") + } + if home != "" { + legacy := filepath.Join(home, ".k10s") + if isDir(legacy) && !isDir(xdg) { + return legacy + } + } + return xdg +} + +func isDir(p string) bool { + fi, err := os.Stat(p) + return err == nil && fi.IsDir() } // Load reads the config file; a missing file returns zero values, nil error. @@ -117,7 +147,7 @@ func Load() (Config, error) { } // Save writes the config file (0600 — it can hold an API key), creating -// ~/.k10s if needed. +// the config directory if needed. func Save(c Config) error { p := Path() if p == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2c2d0cd..77defe6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -186,3 +186,56 @@ func TestCollapsedNoneIsDistinctFromUnset(t *testing.T) { t.Error("a config without the key should read as unset") } } + +func TestPathFollowsXDG(t *testing.T) { + home := t.TempDir() + xdg := filepath.Join(t.TempDir(), "xdg") + t.Setenv("HOME", home) + t.Setenv("K10S_CONFIG", "") + + t.Run("XDG_CONFIG_HOME wins when absolute", func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", xdg) + if got, want := Path(), filepath.Join(xdg, "k10s", "config.yaml"); got != want { + t.Errorf("Path() = %q, want %q", got, want) + } + }) + + for _, bad := range []string{"", "relative/dir"} { + t.Run("falls back to ~/.config for XDG_CONFIG_HOME="+bad, func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", bad) + if got, want := Path(), filepath.Join(home, ".config", "k10s", "config.yaml"); got != want { + t.Errorf("Path() = %q, want %q", got, want) + } + }) + } + + t.Run("K10S_CONFIG beats everything", func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", xdg) + t.Setenv("K10S_CONFIG", "/tmp/x.yaml") + if got := Path(); got != "/tmp/x.yaml" { + t.Errorf("Path() = %q", got) + } + }) +} + +func TestPathKeepsLegacyDirUntilXDGDirExists(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("K10S_CONFIG", "") + t.Setenv("XDG_CONFIG_HOME", "") + + legacy := filepath.Join(home, ".k10s") + if err := os.MkdirAll(legacy, 0o755); err != nil { + t.Fatal(err) + } + if got, want := Path(), filepath.Join(legacy, "config.yaml"); got != want { + t.Errorf("legacy dir only: Path() = %q, want %q", got, want) + } + + if err := os.MkdirAll(filepath.Join(home, ".config", "k10s"), 0o755); err != nil { + t.Fatal(err) + } + if got, want := Path(), filepath.Join(home, ".config", "k10s", "config.yaml"); got != want { + t.Errorf("both dirs: Path() = %q, want %q", got, want) + } +} diff --git a/internal/ui/commands.go b/internal/ui/commands.go index 47780de..b6f2212 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -290,7 +290,7 @@ func Help() string { o cordon/uncordon · u drain (nodes only) the pane lists only the actions that apply to the selected kind D delete (confirm dialog) - plugins ~/.k10s/plugins.yaml shortcuts appear in the same pane; + plugins plugins.yaml shortcuts appear in the same pane; built-in keys win unless override: true COPY / SELECT diff --git a/internal/ui/model.go b/internal/ui/model.go index 70c966f..13f4e4c 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -335,7 +335,7 @@ func New(src domain.Source) *Model { return m } -// loadConfig applies ~/.k10s/config.yaml (or $K10S_CONFIG) on startup. +// loadConfig applies config.Path() ($K10S_CONFIG or the XDG config dir) on startup. func (m *Model) loadConfig() { c, err := config.Load() m.logLimits = c.Logs diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 8aeffcd..4d11b2f 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -10,7 +10,7 @@ import ( "github.com/p10node/k10s/internal/config" ) -// One settings modal covering what is persisted to ~/.k10s/config.yaml and +// One settings modal covering what is persisted to config.yaml and // still adjustable: the CLI name used in hints, and whether k10s looks for a // newer release at startup. It also held the AI provider details until that // feature was switched off (aidisabled.go). diff --git a/uninstall.sh b/uninstall.sh index f8ea93b..dbbd325 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -5,13 +5,13 @@ # # The mirror of install.sh: same shells, same flags where they overlap, same # TTY-aware sudo rule. It only ever deletes a file named k10s (or the -# ~/.k10s directory with --purge), and it prints every path before touching +# config directory with --purge), and it prints every path before touching # it, so a --dry-run first costs nothing. # # Knobs (env or flag): # K10S_INSTALL_DIR=~/bin --dir ~/bin look only there # K10S_NO_SUDO=1 --no-sudo never escalate -# K10S_PURGE=1 --purge also delete ~/.k10s +# K10S_PURGE=1 --purge also delete the config directory set -eu BIN="k10s" @@ -46,8 +46,8 @@ ${B}k10s uninstaller${R} curl -fsSL https://p10node.com/k10s/uninstall.sh | sh -s -- --purge Options - --purge also delete ~/.k10s (config, theme choice) - --keep-config keep ~/.k10s and say nothing about it + --purge also delete the config directory (~/.config/k10s; config, theme choice) + --keep-config keep the config directory and say nothing about it --dir only look in , instead of PATH + the usual places --dry-run print what would be removed, remove nothing -y, --yes no confirmation prompt @@ -116,14 +116,24 @@ else fi # What internal/config calls the config path: $K10S_CONFIG wins, otherwise -# ~/.k10s/config.yaml. With $K10S_CONFIG set, only that file is ours to -# delete — the directory around it belongs to whoever chose the path. +# $XDG_CONFIG_HOME/k10s (~/.config/k10s when unset or not absolute), else +# the pre-XDG ~/.k10s while it is the only one present. With $K10S_CONFIG set, +# only that file is ours to delete — the directory around it belongs to +# whoever chose the path. CONFIG_DIR="" CONFIG_FILE="" if [ -n "${K10S_CONFIG:-}" ]; then [ -f "$K10S_CONFIG" ] && CONFIG_FILE="$K10S_CONFIG" -elif [ -d "$HOME/.k10s" ]; then - CONFIG_DIR="$HOME/.k10s" +else + case "${XDG_CONFIG_HOME:-}" in + /*) xdg_base="$XDG_CONFIG_HOME" ;; + *) xdg_base="$HOME/.config" ;; + esac + if [ -d "$xdg_base/k10s" ]; then + CONFIG_DIR="$xdg_base/k10s" + elif [ -d "$HOME/.k10s" ]; then + CONFIG_DIR="$HOME/.k10s" + fi fi CONFIG_TARGET="${CONFIG_DIR:-$CONFIG_FILE}"