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:
1 parent
786768ec53
commit
c2e590bf52
6 files changed
+445
-1
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user