From c2e590bf52946a640173320b8d0f7f4efef10f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Wed, 28 Oct 2026 11:23:28 +0100 Subject: [PATCH] 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- 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 --- cmd/netssh/main.go | 3 +- cmd/netssh/uninstall.go | 250 +++++++++++++++++++++++++++++++++++ cmd/netssh/uninstall_test.go | 119 +++++++++++++++++ install.sh | 1 + internal/hook/completion.go | 43 ++++++ internal/hook/hook.go | 30 +++++ 6 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 cmd/netssh/uninstall.go create mode 100644 cmd/netssh/uninstall_test.go diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index 0f8743f..fbd7f7d 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -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() diff --git a/cmd/netssh/uninstall.go b/cmd/netssh/uninstall.go new file mode 100644 index 0000000..dc01240 --- /dev/null +++ b/cmd/netssh/uninstall.go @@ -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 +} diff --git a/cmd/netssh/uninstall_test.go b/cmd/netssh/uninstall_test.go new file mode 100644 index 0000000..e4d26f6 --- /dev/null +++ b/cmd/netssh/uninstall_test.go @@ -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") + } +} diff --git a/install.sh b/install.sh index b723154..2d5b892 100755 --- a/install.sh +++ b/install.sh @@ -258,3 +258,4 @@ for kind in "${COMPLETIONS[@]}"; do info "zsh: if still shows files, run: rm -f $(quote_sh "$ZSH_DIR")/.zcompdump* && exec zsh" fi done +info "Remove netssh again with: netssh uninstall" diff --git a/internal/hook/completion.go b/internal/hook/completion.go index 11d1495..e7d8b4d 100644 --- a/internal/hook/completion.go +++ b/internal/hook/completion.go @@ -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") +} diff --git a/internal/hook/hook.go b/internal/hook/hook.go index 84ca2b0..d8ea77f 100644 --- a/internal/hook/hook.go +++ b/internal/hook/hook.go @@ -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")) +}