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>
251 lines
6.9 KiB
Go
251 lines
6.9 KiB
Go
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
|
|
}
|