Skip to content

Modernize kuse: Go 1.25, lean deps, tests, and reliability fixes - #22

Merged
sofuture merged 4 commits into
mainfrom
cursor/modernize-kuse-2a9b
Sep 7, 2026
Merged

sofuture merged 4 commits into
mainfrom
cursor/modernize-kuse-2a9b

Conversation

@sofuture

@sofuture sofuture commented Sep 7, 2026 •

Copy link
Copy Markdown
Owner

Summary

Modernizes and hardens kuse end-to-end: toolchain, leaner dependencies, reliability fixes, tests, CLI UX, and CI/release workflows.

Changes

  • Go 1.25 toolchain; updated CLI deps (go-arg 1.6.1)
  • Dropped Viper + go-homedir in favor of typed yaml.v3 config + stdlib home expansion (much smaller module graph)
  • Reliability: filepath usage, dangling-symlink detection, relative symlink resolution, auto-create default sources dir, sorted targets, skip hidden source files, partial CLI overrides preserve other saved settings
  • Review follow-ups: restore current-kubeconfig errors on status (stderr), reject ~user paths, absolutize resolved paths, validate before persisting config, atomic temp-symlink + rename for switches/--force, make check parity with CI, go-arg Versioned, docs updates
  • CLI: --version (ldflags / Versioned), --force/-f, help text, errors on stderr
  • Tests: util/config/state coverage including force overwrite, dangling symlink replace, ~user rejection, and status error surfacing
  • Tooling: Makefile (test/vet/check with -race), CI via make check, Dependabot for Actions, Actions bumped to current majors, release embeds version

Test plan

  • make check
  • Manual smoke: defaults, switch, --short, --force, dangling symlink, ~user rejection, invalid target
  • CI green on prior revisions
  • CI green on review-fix revision
Open in Web Open in Cursor 

sofuture and others added 2 commits September 7, 2026 18:40
Bump the toolchain and dependencies, replace archived go-homedir with
stdlib home expansion, and harden path/symlink handling. Add unit tests,
improve the Makefile/CI/release workflows, and polish CLI UX (version
flag, stderr errors, sorted targets, auto-create dirs).

Co-authored-by: Jeff Zellner <hello@jeffzellner.com>
Drop the heavy Viper dependency in favor of a small typed YAML config
loader that preserves partial CLI overrides. Add --force/-f for
non-interactive overwrite of a regular kubeconfig file, and skip hidden
source files.

Co-authored-by: Jeff Zellner <hello@jeffzellner.com>
@cursor cursor Bot changed the title Modernize kuse: Go 1.25, deps, tests, and reliability fixes Modernize kuse: Go 1.25, lean deps, tests, and reliability fixes Sep 7, 2026
@cursor

cursor Bot commented Sep 7, 2026

Copy link
Copy Markdown

Review

Solid modernization. I'd merge after addressing two behavior regressions I was able to reproduce by building both branches and running them side by side.

Independently verified on this branch: go vet ./... passes, go test -race -count=1 ./... passes (also under -shuffle=on), and go mod tidy is a no-op against the committed go.mod/go.sum.

I also checked the Actions bumps, since the release workflow only fires on tags and CI can't cover it. actions/checkout@v7, actions/setup-go@v7, actions/upload-artifact@v7, actions/download-artifact@v8, and softprops/action-gh-release@v3 all exist, and every input the workflow passes (pattern, merge-multiple, if-no-files-found) is still valid in those majors.

The headline fix is genuine. On main, switching targets when ~/.kube/config is a dangling symlink fails outright, because the old exists() used os.Stat and reported false, so os.Symlink then hit EEXIST:

=== main ===
error: symlink /tmp/dang/kubeconfigs/dev.yaml /tmp/dang/.kube/config: file exists
=== PR ===
set kubeconfig to: /tmp/dang/kubeconfigs/dev.yaml

Two regressions worth fixing before merge

1. The improved error messages are constructed and then thrown away.

loadCurrent now returns nicely wrapped errors, but LoadState discards them:

	if err := s.loadCurrent(); err != nil {
		s.current.Name = "~none~"
	}

main printed the reason; this version prints nothing. With a regular file at ~/.kube/config:

main:  kubeconfig is not a symlink
       kuse current target: ~none~
PR:    kuse current target: ~none~

A user whose kubeconfig is a real file now gets ~none~ with no hint about why, or that kuse --force <name> is the way out. Suggest storing the error on State and having PrintStatusCommand write it to stderr, leaving --short silent so prompt integrations stay clean.

2. ~user paths silently become garbage relative directories.

expandHome passes anything that isn't exactly ~ or ~/-prefixed straight through:

func expandHome(p string) (string, error) {
	if p == "~" {
		return os.UserHomeDir()
	}
	if strings.HasPrefix(p, "~/") || strings.HasPrefix(p, `~\`) {
		...
	}
	return p, nil
}

go-homedir returned cannot expand user-specific home dir here. Now, combined with the new unconditional os.MkdirAll(expandedSources, ...) in InitConfig:

$ cd /tmp/scratch && kuse --sources '~someuser/cfgs'
No kuse configuration found; creating defaults at /tmp/scratch/.config/kuse/kuseconfig.yaml
kuse current target: ~none~
available targets: []
$ echo $?
0
$ ls -d /tmp/scratch/~someuser
/tmp/scratch/~someuser
$ cat /tmp/scratch/.config/kuse/kuseconfig.yaml
kubeconfig: ~/.kube/config
sources: ~someuser/cfgs

It exits 0, creates a literal ./~someuser/cfgs in the current working directory, and persists the bad value, so every later kuse invocation resolves sources relative to whatever directory you happen to be in. On main the same command exits 1 with error: cannot expand user-specific home dir.

Rejecting ~ followed by anything other than / restores the old behavior, and running the persisted path through filepath.Abs would close the more general relative-path hole.

A milder version of the same interaction: a typo'd --sources is now silently created and saved rather than failing with "no such file or directory," so the recovery path is less obvious than it was.


One robustness point on --force

switchLink removes the existing kubeconfig and only then creates the replacement:

		if err := os.Remove(s.config.Kubeconfig); err != nil {
			return fmt.Errorf("remove existing kubeconfig: %w", err)
		}
	}

	if err := os.MkdirAll(filepath.Dir(s.config.Kubeconfig), 0o755); err != nil {
		return fmt.Errorf("create kubeconfig directory: %w", err)
	}

	if err := os.Symlink(target, s.config.Kubeconfig); err != nil {

That ordering predates this PR, but --force turns "delete the user's real kubeconfig" from an interactive confirmation into a single flag, so a failure between the two steps is now much cheaper to trigger and destroys unrecoverable content. Symlinking to a temp name in the same directory and os.Rename-ing over the target would make the swap atomic and never leave the user with nothing.


Smaller things

  • var args args shadows the type with a variable of the same name. It compiles, but it's confusing and blocks any later reference to the type.
  • go-arg has a built-in Versioned interface (func (args) Version() string) that provides --version without a bool field.
  • PrintStatusCommand inlines the same names-building loop that TargetNames() already implements; it can just call it.
  • CurrentName() and TargetNames() are exported solely for tests that live in package common and could read the unexported fields directly. That adds public API with no external consumer.
  • sort.Slice could be slices.SortFunc now that the floor is Go 1.25.
  • go 1.25.0 with no toolchain line means the newly advertised go install github.com/sofuture/kuse/cmd/kuse@latest requires a very recent Go on the user's machine. Worth a conscious decision for a tool this small.
  • make test runs without -race while CI runs go test -race -count=1 directly and never invokes the new make check target, so local and CI checks can diverge. Pointing CI at make check and adding -race to test would keep them in sync.
  • The README help block omits the description line that --help actually prints first.
  • The new "hidden source files are skipped" behavior isn't documented in the README.

Surface loadCurrent failures on status (stderr), reject ~user home
expansion, absolutize resolved paths, validate before writing config,
and swap kubeconfigs via temp symlink + rename. Also align Makefile/CI
checks, use go-arg Versioned, and document hidden-file skipping.

Co-authored-by: Jeff Zellner <hello@jeffzellner.com>
@cursor

cursor Bot commented Sep 7, 2026

Copy link
Copy Markdown

Addressed the review feedback in ec1649b:

Regressions

  1. LoadState now keeps currentErr; PrintStatusCommand writes it to stderr (with a --force hint for non-symlink kubeconfigs). --short stays silent.
  2. expandHome rejects ~user... again (cannot expand user-specific home dir), paths are run through filepath.Abs, and path validation happens before writing config so bad values are not persisted. Override --sources no longer auto-creates missing dirs.

--force robustness
Switches now create a temp symlink then os.Rename over the destination, so a failed swap no longer deletes the existing kubeconfig first.

Smaller items

  • Renamed CLI args type to avoid var args args shadowing; switched to go-arg Versioned
  • PrintStatusCommand uses shared targetNames(); dropped test-only exported getters
  • slices.SortFunc; kept Go 1.25 deliberately and noted it in the README for go install
  • make test now uses -race -count=1; CI runs make check
  • README help block + hidden-file skipping docs updated

@cursor

cursor Bot commented Sep 7, 2026

Copy link
Copy Markdown

Re-review of ec1649b

Every point from the previous review was addressed. I verified the three substantive fixes at runtime rather than just reading the diff.

go vet ./..., make check, and three shuffled -race runs all pass locally, and CI is green on this revision.


The three real issues are fixed

1. Swallowed status errors. State now carries a currentErr, written to stderr while stdout and --short stay untouched. Nice touch adding the --force hint:

$ kuse 2>/dev/null              # stdout
kuse current target: ~none~
available targets: [dev]

$ kuse 2>&1 >/dev/null          # stderr
kubeconfig is not a symlink: /tmp/f1/.kube/config (use kuse --force <name> to replace it)

$ kuse --short
~none~

2. ~user paths. expandHome now rejects any ~ form it can't expand, and resolvePath runs before the config write. The exact command that previously exited 0 while littering the filesystem:

$ kuse --sources '~someuser/cfgs'
error: expand sources path: cannot expand user-specific home dir
$ echo $?
1

No stray ~someuser directory, and no config file written. Confirmed both.

3. Non-atomic swap. switchLink no longer unlinks the destination; it symlinks to a temp name in the same directory and renames over the target. I forced the symlink step to fail, and the original survived intact with no temp file left behind:

$ kuse --force dev
error: create symlink: symlink .../dev.yaml /tmp/f4/.kube/.kuse-12725-1788808032080411222: permission denied
$ cat /tmp/f4/.kube/config
PRECIOUS-KUBECONFIG

Bonus: the typo'd---sources concern is handled too. Auto-creation is now gated on creatingDefaults, so an explicit override pointing nowhere fails loudly:

$ kuse --sources /tmp/f2b/kubeconfgs
error: read sources directory: open /tmp/f2b/kubeconfgs: no such file or directory

All the smaller items landed as well: cliArgs/opts instead of the shadowing var args args, go-arg's Versioned interface, slices.SortFunc, targetNames() unexported and reused by PrintStatusCommand, make test gaining -race with CI switched to make check, and README coverage for the Go 1.25 floor, the description line, and hidden-file skipping.

Test coverage grew meaningfully too — TestSetTargetReplacesDanglingSymlink in particular finally exercises the headline bug fix end to end rather than just the exists() helper.


One thing only half-closed (non-blocking)

Absolutizing happens at resolution time, but the config still persists the raw value, so a relative --sources produces a saved config that means different things in different directories:

$ cd /tmp/f3/a && kuse --sources ./cfgs
available targets: [from-a]

$ cat ~/.config/kuse/kuseconfig.yaml
kubeconfig: ~/.kube/config
sources: ./cfgs

$ cd /tmp/f3/b && kuse
available targets: [from-b]

Writing the resolved absolute path instead of raw in writeFileConfig(cfgLocation, raw) would finish this off. Much less severe than the ~user case, since a user typing a relative path is at least choosing it deliberately.

Two minor notes

  • A typo'd --sources is still persisted before the failure surfaces, so plain kuse keeps failing until you re-run with a correct value. Recoverable (the write precedes the read), just mildly surprising.
  • TestPrintStatusCommandWritesCurrentError reassigns os.Stderr and restores it inline. A t.Cleanup would stop a t.Fatal in the middle from leaving stderr redirected for the rest of the package run.

LGTM — ready to merge from my side.

Write resolved absolute kubeconfig/sources paths so relative CLI values
keep a stable meaning across working directories. Validate the sources
directory before any config write, and restore stderr via t.Cleanup in
the status error test.

Co-authored-by: Jeff Zellner <hello@jeffzellner.com>
@cursor

cursor Bot commented Sep 7, 2026

Copy link
Copy Markdown

Follow-up in 40b9dce:

  1. Config writes now persist resolved absolute kubeconfig/sources paths, so kuse --sources ./cfgs keeps the same targets after cd elsewhere.
  2. Sources directory is validated before any config write — a typo'd --sources fails without rewriting an existing config (and does not create a new broken one).
  3. TestPrintStatusCommandWritesCurrentError restores os.Stderr via t.Cleanup.

@sofuture
sofuture marked this pull request as ready for review September 7, 2026 19:46
@sofuture
sofuture merged commit c57212c into main Sep 7, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants