171 lines
5.2 KiB
Go
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
|
|
}
|