Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -334,7 +334,7 @@ talk to. `:ctx <name>` 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).

Expand Down
6 changes: 3 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/shot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
20 changes: 13 additions & 7 deletions docs/config.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` | `K10S_INSTALL_DIR` | look only there |
| `--dry-run`, `-n` | — | list, remove nothing |
Expand All @@ -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
```
2 changes: 1 addition & 1 deletion docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
4 changes: 2 additions & 2 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions docs/themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion docs/update.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion examples/themes/rose-pine.yaml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
44 changes: 37 additions & 7 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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.
Expand All @@ -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 == "" {
Expand Down
53 changes: 53 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
2 changes: 1 addition & 1 deletion internal/ui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading
Loading