feat: add netssh uninstall

Remove everything netssh and install.sh set up, also leftovers of
older versions:

- netssh blocks and unmarked legacy lines in bash, zsh, and fish
  profiles
- completion files for bash, zsh (incl. the oh-my-zsh cache), fish,
  and the command alias; files not generated by netssh are kept
- the cache directory with hosts, aliases, and per-instance caches
- the config file and local notes; the config is first copied to a
  private netssh.yaml.backup-<timestamp> because it holds API tokens
- the netssh binary; for protected paths the sudo command is printed

--keep-config keeps config and notes, --dry-run only lists the plan,
and --yes skips the confirmation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sebastian UnterschützandClaude Opus 5.5 committed 2026-10-28 11:23:28 +01:00
1 parent 786768ec53
commit c2e590bf52
6 files changed
+445 -1

No files matched your search

+2 -1
View File
@@ -41,6 +41,7 @@ var managedSubcommands = map[string]bool{
"__complete": true,
"help": true,
"version": true,
"uninstall": true,
}
func main() {
@@ -357,7 +358,7 @@ func rootCmd() *cobra.Command {
},
}
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd())
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd())
// cobra builds the "completion" command lazily; force init so we can extend it.
root.InitDefaultCompletionCmd()
+250
View File
@@ -0,0 +1,250 @@
package main
import (
"bufio"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
)
// uninstallPlan lists everything netssh has written to this machine.
type uninstallPlan struct {
Profiles []string // shell profiles with a netssh block or legacy lines
Paths []string // files and directories to delete
Config string // config file to back up and delete; empty when kept
Binaries []string
}
func buildUninstallPlan(cfg *config.Config, keepConfig bool) uninstallPlan {
var plan uninstallPlan
seen := make(map[string]bool)
addPath := func(path string) {
if path == "" || seen[path] {
return
}
if _, err := os.Lstat(path); err == nil {
seen[path] = true
plan.Paths = append(plan.Paths, path)
}
}
for _, profile := range hook.ProfileCandidates() {
target, err := filepath.EvalSymlinks(profile)
if err != nil || seen[target] {
continue
}
seen[target] = true
if hook.HasEntries(profile) {
plan.Profiles = append(plan.Profiles, profile)
}
}
aliases := []string{hook.CompletionAliasState()}
if cfg != nil {
aliases = append(aliases, cfg.Shell.Alias)
}
for _, path := range hook.CompletionFiles(aliases...) {
if hook.IsGenerated(path) {
addPath(path)
}
}
// The cache directory holds hosts, aliases, refresh stamps, and instance caches.
cacheDir := filepath.Dir(hook.AliasesPath())
addPath(cacheDir)
if cfg != nil {
for _, path := range []string{cfg.Cache.Path, cfg.CachePath()} {
if path == "" || strings.HasPrefix(path, cacheDir+string(filepath.Separator)) {
continue
}
addPath(path)
addPath(path + ".lock")
addPath(filepath.Join(filepath.Dir(path), "last_refresh"))
if strings.HasPrefix(filepath.Base(filepath.Dir(path)), "instances-") {
addPath(filepath.Dir(path))
}
}
}
if !keepConfig {
if _, err := os.Stat(config.Path()); err == nil {
plan.Config = config.Path()
}
// Local server notes live next to the config file.
addPath(filepath.Join(filepath.Dir(config.Path()), "netssh"))
}
plan.Binaries = installedBinaries()
return plan
}
// installedBinaries finds the running binary and any other netssh in PATH.
func installedBinaries() []string {
var out []string
seen := make(map[string]bool)
add := func(path string) {
real, err := filepath.EvalSymlinks(path)
if err != nil || seen[real] {
return
}
if info, err := os.Stat(real); err != nil || info.IsDir() || filepath.Base(real) != "netssh" {
return
}
seen[real] = true
out = append(out, real)
}
if self, err := os.Executable(); err == nil {
add(self)
}
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
if dir != "" {
add(filepath.Join(dir, "netssh"))
}
}
return out
}
func (p uninstallPlan) empty() bool {
return len(p.Profiles) == 0 && len(p.Paths) == 0 && p.Config == "" && len(p.Binaries) == 0
}
func (p uninstallPlan) print(w io.Writer) {
for _, profile := range p.Profiles {
fmt.Fprintf(w, " edit %s (remove netssh entries)\n", profile)
}
for _, path := range p.Paths {
fmt.Fprintf(w, " delete %s\n", path)
}
if p.Config != "" {
fmt.Fprintf(w, " delete %s (a backup is kept)\n", p.Config)
}
for _, binary := range p.Binaries {
fmt.Fprintf(w, " delete %s\n", binary)
}
}
// execute applies the plan. It continues after errors so that one protected
// file does not leave the rest behind, and returns commands that need sudo.
func (p uninstallPlan) execute(w io.Writer) (manual []string, err error) {
var errs []error
for _, profile := range p.Profiles {
if _, err := hook.Uninstall(profile); err != nil {
errs = append(errs, err)
continue
}
fmt.Fprintf(w, "cleaned %s\n", profile)
}
for _, path := range p.Paths {
if err := os.RemoveAll(path); err != nil {
errs = append(errs, err)
continue
}
fmt.Fprintf(w, "removed %s\n", path)
}
if p.Config != "" {
backup, err := backupConfig(p.Config)
if err != nil {
errs = append(errs, fmt.Errorf("config not removed: %w", err))
} else if err := os.Remove(p.Config); err != nil {
errs = append(errs, err)
} else {
fmt.Fprintf(w, "removed %s (backup: %s)\n", p.Config, backup)
}
}
for _, binary := range p.Binaries {
if err := os.Remove(binary); err != nil {
if errors.Is(err, fs.ErrPermission) {
manual = append(manual, "sudo rm -f "+shellQuote(binary))
continue
}
errs = append(errs, err)
continue
}
fmt.Fprintf(w, "removed %s\n", binary)
}
return manual, errors.Join(errs...)
}
// backupConfig keeps a private copy, because the config holds API tokens.
func backupConfig(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
backup := path + ".backup-" + time.Now().Format("20060102-150405")
if err := storage.WriteFile(backup, data, 0o600); err != nil {
return "", err
}
return backup, nil
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func uninstallCmd() *cobra.Command {
var keepConfig, yes, dryRun bool
cmd := &cobra.Command{
Use: "uninstall",
Short: "Remove netssh, its shell integration, cache, and config",
Long: `Removes everything netssh and install.sh have set up:
- netssh blocks and unmarked entries of older versions in bash, zsh, and fish profiles
- shell completion files (bash, zsh, fish, oh-my-zsh cache, command alias)
- the cache directory with hosts, aliases, and per-instance caches
- the config file (a private backup is kept) and local server notes
- the netssh binary
Use --keep-config to keep the config file and notes for a reinstall.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
// A broken config must not block the uninstall.
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
cfg = nil
}
plan := buildUninstallPlan(cfg, keepConfig)
out := cmd.OutOrStdout()
if plan.empty() {
fmt.Fprintln(out, "Nothing to remove.")
return nil
}
fmt.Fprintln(out, "netssh uninstall will:")
plan.print(out)
if dryRun {
return nil
}
if !yes {
fmt.Fprint(out, "Proceed? [y/N] ")
answer, _ := bufio.NewReader(cmd.InOrStdin()).ReadString('\n')
if a := strings.ToLower(strings.TrimSpace(answer)); a != "y" && a != "yes" {
return fmt.Errorf("aborted")
}
}
manual, err := plan.execute(out)
for _, command := range manual {
fmt.Fprintf(out, "Permission denied. Run: %s\n", command)
}
if len(plan.Profiles) > 0 {
fmt.Fprintln(out, "Open a new terminal. For zsh, also run: rm -f ~/.zcompdump*")
}
return err
},
}
cmd.Flags().BoolVar(&keepConfig, "keep-config", false, "Keep the config file and local notes")
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Do not ask for confirmation")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Only show what would be removed")
return cmd
}
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
)
func uninstallFixture(t *testing.T) (home string) {
t.Helper()
home = t.TempDir()
t.Setenv("HOME", home)
t.Setenv("ZDOTDIR", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
t.Setenv("ZSH", "")
t.Setenv("ZSH_CACHE_DIR", "")
t.Setenv("NETSSH_SHELL_ALIAS", "")
t.Setenv("NETSSH_CACHE_PATH", "")
t.Setenv("PATH", filepath.Join(home, "nobin"))
files := map[string]string{
".bashrc": "export KEEP=1\n# >>> netssh >>>\nnetssh shell-init\n# <<< netssh <<<\n",
".zshrc": "plugins=(git)\nnetssh shell-init # netssh cache auto-refresh\nsource ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases\n",
".profile": "export UNRELATED=1\n",
".config/netssh.yaml": "netbox:\n url: \"https://netbox.example.com\"\n token: \"secret\"\nshell:\n alias: \"j\"\n",
".config/netssh/notes/abc.txt": "note",
".cache/netssh/hosts.json": "{}",
".cache/netssh/instances-0011/hosts.json": "{}",
".local/share/bash-completion/completions/netssh": "# bash completion for netssh",
".zfunc/_netssh": hook.ZshCompletionScript,
".zfunc/_j": "#compdef j\n# netssh command alias completion — generated automatically\n",
".zfunc/_other": "#compdef other\n",
}
for name, content := range files {
path := filepath.Join(home, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
return home
}
func runUninstall(t *testing.T, args ...string) string {
t.Helper()
cmd := rootCmd()
var out strings.Builder
cmd.SetOut(&out)
cmd.SetArgs(append([]string{"uninstall"}, args...))
if err := cmd.Execute(); err != nil {
t.Fatalf("uninstall: %v\n%s", err, out.String())
}
return out.String()
}
func TestUninstallRemovesEverythingAndBacksUpConfig(t *testing.T) {
home := uninstallFixture(t)
if out := runUninstall(t, "--dry-run"); !strings.Contains(out, ".zshrc") || strings.Contains(out, ".profile") {
t.Fatalf("plan:\n%s", out)
}
if _, err := os.Stat(filepath.Join(home, ".cache", "netssh")); err != nil {
t.Fatal("dry run removed files")
}
runUninstall(t, "--yes")
for name, want := range map[string]string{".bashrc": "export KEEP=1\n", ".zshrc": "plugins=(git)\n", ".profile": "export UNRELATED=1\n"} {
data, _ := os.ReadFile(filepath.Join(home, name))
if string(data) != want {
t.Errorf("%s: %q", name, data)
}
}
for _, gone := range []string{".cache/netssh", ".config/netssh.yaml", ".config/netssh", ".zfunc/_netssh", ".zfunc/_j", ".local/share/bash-completion/completions/netssh"} {
if _, err := os.Stat(filepath.Join(home, gone)); !os.IsNotExist(err) {
t.Errorf("%s not removed: %v", gone, err)
}
}
if _, err := os.Stat(filepath.Join(home, ".zfunc", "_other")); err != nil {
t.Error("unrelated completion removed")
}
backups, _ := filepath.Glob(filepath.Join(home, ".config", "netssh.yaml.backup-*"))
if len(backups) != 1 {
t.Fatalf("backups: %v", backups)
}
if info, _ := os.Stat(backups[0]); info.Mode().Perm() != 0o600 {
t.Error("backup with token is not private")
}
}
func TestUninstallKeepConfig(t *testing.T) {
home := uninstallFixture(t)
runUninstall(t, "--yes", "--keep-config")
for _, kept := range []string{".config/netssh.yaml", ".config/netssh/notes/abc.txt"} {
if _, err := os.Stat(filepath.Join(home, kept)); err != nil {
t.Errorf("%s removed", kept)
}
}
if _, err := os.Stat(filepath.Join(home, ".cache", "netssh")); !os.IsNotExist(err) {
t.Error("cache kept")
}
}
func TestUninstallAbortsWithoutConfirmation(t *testing.T) {
home := uninstallFixture(t)
cmd := rootCmd()
cmd.SetOut(&strings.Builder{})
cmd.SetIn(strings.NewReader("n\n"))
cmd.SetArgs([]string{"uninstall"})
if err := cmd.Execute(); err == nil {
t.Fatal("no confirmation required")
}
if _, err := os.Stat(filepath.Join(home, ".config", "netssh.yaml")); err != nil {
t.Fatal("config removed without confirmation")
}
}
+1
View File
@@ -258,3 +258,4 @@ for kind in "${COMPLETIONS[@]}"; do
info "zsh: if <Tab> still shows files, run: rm -f $(quote_sh "$ZSH_DIR")/.zcompdump* && exec zsh"
fi
done
info "Remove netssh again with: netssh uninstall"
+43
View File
@@ -114,3 +114,46 @@ complete --command %s --wraps netssh
`, completionMarker, alias)
}
}
// CompletionFiles lists the completion and alias files netssh may have
// installed for itself and for the given command aliases.
func CompletionFiles(aliases ...string) []string {
files := []string{
filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", "netssh"),
filepath.Join(ZshFuncDir(), "_netssh"),
filepath.Join(FishConfigDir(), "completions", "netssh.fish"),
FishAliasesPath(),
}
// oh-my-zsh caches generated completions separately.
for _, dir := range []string{os.Getenv("ZSH_CACHE_DIR"), filepath.Join(os.Getenv("ZSH"), "cache"), filepath.Join(os.Getenv("HOME"), ".oh-my-zsh", "cache")} {
if dir != "" && dir != "cache" {
files = append(files, filepath.Join(dir, "completions", "_netssh"))
}
}
for _, alias := range aliases {
if config.ValidateCommandAlias(alias) != nil || alias == "" {
continue
}
for _, shell := range []string{"bash", "zsh", "fish"} {
files = append(files, commandCompletionPath(shell, alias))
}
}
return files
}
// CompletionAliasState returns the command alias whose completion files were
// last generated, or "" when there is none.
func CompletionAliasState() string {
data, err := os.ReadFile(filepath.Join(filepath.Dir(AliasesPath()), "completion_alias"))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
// IsGenerated reports whether a completion file was written by netssh,
// so uninstall never removes a user's own file of the same name.
func IsGenerated(path string) bool {
data, err := os.ReadFile(path)
return err == nil && strings.Contains(string(data), "netssh")
}
+30
View File
@@ -166,6 +166,16 @@ func isLegacyLine(line string) bool {
return false
}
// HasEntries reports whether the profile contains a managed block or legacy lines.
func HasEntries(profilePath string) bool {
data, err := os.ReadFile(profilePath)
if err != nil {
return false
}
_, removed, err := clean(string(data), profilePath)
return removed || err != nil
}
// Uninstall removes legacy hook lines and complete installer-managed blocks.
// Missing profiles are a no-op; malformed blocks leave the file unchanged.
func Uninstall(profilePath string) (removed bool, err error) {
@@ -248,3 +258,23 @@ func writeProfile(profilePath, content string) error {
}
return nil
}
// ProfileCandidates lists every shell profile netssh or install.sh may have
// written to, in the same order as the installer.
func ProfileCandidates() []string {
home := os.Getenv("HOME")
zdot := zshDir()
paths := []string{
filepath.Join(home, ".bashrc"),
filepath.Join(home, ".bash_profile"),
filepath.Join(home, ".bash_login"),
filepath.Join(home, ".profile"),
filepath.Join(zdot, ".zshrc"),
filepath.Join(zdot, ".zprofile"),
filepath.Join(zdot, ".zshenv"),
}
if zdot != home {
paths = append(paths, filepath.Join(home, ".zshrc"), filepath.Join(home, ".zprofile"), filepath.Join(home, ".zshenv"))
}
return append(paths, filepath.Join(FishConfigDir(), "config.fish"))
}