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 }