package hook import ( "fmt" "os" "path/filepath" "regexp" "strings" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage" ) const ( // Marker is the unique string used to detect an existing hook line. Marker = "netssh shell-init" BlockStart = "# >>> netssh >>>" BlockEnd = "# <<< netssh <<<" // Line is the full line written into the shell profile. Line = "netssh shell-init # netssh cache auto-refresh" // AliasesMarker is the unique string used to detect the aliases source line. AliasesMarker = "netssh/aliases.sh" // AliasesSourceLine is the bash/zsh line that sources the generated aliases file. AliasesSourceLine = "source ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases" ) // ProfilePath returns the canonical shell profile path for the given shell name. func ProfilePath(shell string) (string, error) { switch shell { case "bash": return filepath.Join(os.Getenv("HOME"), ".bashrc"), nil case "zsh": dir := os.Getenv("ZDOTDIR") if dir == "" { dir = os.Getenv("HOME") } return filepath.Join(dir, ".zshrc"), nil case "fish": return filepath.Join(FishConfigDir(), "config.fish"), nil default: return "", fmt.Errorf("unsupported shell %q — supported: bash, zsh, fish", shell) } } // ReloadNote returns the shell-specific hint for reloading the profile. func ReloadNote(profilePath string) string { return "Reload with: source " + profilePath } // IsInstalled reports whether the hook marker is present in the profile file. // Returns false if the file does not exist or cannot be read. func IsInstalled(profilePath string) bool { data, err := os.ReadFile(profilePath) if err != nil { return false } for _, line := range strings.Split(string(data), "\n") { if strings.TrimSpace(line) == BlockStart || hookLinePattern.MatchString(strings.TrimSpace(line)) { return true } } return false } // Install appends the hook line to the profile if it is not already present. // Returns (true, nil) when newly installed, (false, nil) when already present. // Creates the profile file (and any parent directories) if they do not exist. func Install(profilePath string) (installed bool, err error) { if IsInstalled(profilePath) { return false, nil } if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil { return false, fmt.Errorf("creating directory: %w", err) } f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return false, fmt.Errorf("opening profile: %w", err) } defer f.Close() if _, err := fmt.Fprintf(f, "\n%s\n", Line); err != nil { return false, err } return true, nil } // InstallAliasesSource appends the aliases source line to a bash/zsh profile. // This is a no-op for fish (which uses conf.d auto-sourcing instead). // Returns (true, nil) when newly added, (false, nil) when already present. func InstallAliasesSource(profilePath string) (installed bool, err error) { data, err := os.ReadFile(profilePath) if err != nil && !os.IsNotExist(err) { return false, fmt.Errorf("reading profile: %w", err) } for _, line := range strings.Split(string(data), "\n") { if strings.TrimSpace(line) == BlockStart || aliasesLinePattern.MatchString(strings.TrimSpace(line)) { return false, nil } } f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return false, fmt.Errorf("opening profile: %w", err) } defer f.Close() if _, err := fmt.Fprintf(f, "%s\n", "source "+shellQuote(AliasesPath(), "bash")+" 2>/dev/null # netssh aliases"); err != nil { return false, err } return true, nil } var hookLinePattern = regexp.MustCompile(`^(?:[^\s]*/)?netssh\s+shell-init\s*(?:#.*)?$`) var aliasesLinePattern = regexp.MustCompile(`^(?:source|\.)\s+["']?[^;|&]*netssh/aliases\.sh["']?(?:\s+2>/dev/null)?\s*(?:#.*)?$`) // 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) { data, err := os.ReadFile(profilePath) if os.IsNotExist(err) { return false, nil } if err != nil { return false, fmt.Errorf("reading profile: %w", err) } var kept []string inside := false for _, line := range strings.Split(string(data), "\n") { value := strings.TrimSpace(line) switch value { case BlockStart: if inside { return false, fmt.Errorf("nested netssh block in %s", profilePath) } inside = true removed = true continue case BlockEnd: if !inside { return false, fmt.Errorf("unmatched netssh block in %s", profilePath) } inside = false continue } if inside { continue } if hookLinePattern.MatchString(value) || aliasesLinePattern.MatchString(value) { removed = true continue } kept = append(kept, line) } if inside { return false, fmt.Errorf("unclosed netssh block in %s", profilePath) } if !removed { return false, nil } cleaned := strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n") target, err := filepath.EvalSymlinks(profilePath) if err != nil { return false, err } info, err := os.Stat(target) if err != nil { return false, err } if err := storage.WriteFile(target, []byte(cleaned), info.Mode().Perm()); err != nil { return false, fmt.Errorf("writing profile: %w", err) } return true, nil }