Files
ssh-netbox-wrapper/internal/hook/hook.go
T
2026-10-27 18:10:44 +01:00

171 lines
5.2 KiB
Go

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
}