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 writes the managed netssh block into the profile. Entries written by // older versions without markers are replaced instead of duplicated; an // existing managed block (e.g. from install.sh) is left untouched. // Returns (true, nil) when the profile changed, (false, nil) when already present. // Creates the profile file (and any parent directories) if they do not exist. func Install(profilePath, shell 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 { return false, nil } } cleaned, _, err := clean(string(data), profilePath) if err != nil { return false, err } if cleaned != "" && !strings.HasSuffix(cleaned, "\n") { cleaned += "\n" } if cleaned != "" { cleaned += "\n" } cleaned += Block(shell) if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil { return false, fmt.Errorf("creating directory: %w", err) } if err := writeProfile(profilePath, cleaned); err != nil { return false, err } return true, nil } // Block returns the managed profile block for shell. func Block(shell string) string { var b strings.Builder b.WriteString(BlockStart + "\n") b.WriteString("netssh shell-init\n") if shell != "fish" { aliases := shellQuote(AliasesPath(), "bash") fmt.Fprintf(&b, "[ ! -f %s ] || . %s\n", aliases, aliases) } if shell == "zsh" { b.WriteString(ZshRegistration(ZshFuncDir())) } b.WriteString(BlockEnd + "\n") return b.String() } // ZshRegistration adds dir to fpath and registers the completion even when // compinit already ran earlier in .zshrc (e.g. via oh-my-zsh). func ZshRegistration(dir string) string { return fmt.Sprintf("fpath=(%s $fpath)\nif (( $+functions[compdef] )); then autoload -Uz _netssh && compdef _netssh netssh; fi\n", shellQuote(dir, "bash")) } // 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*(?:#.*)?$`) // legacyPatterns match the unmarked lines written by older versions and by // manual setup instructions. They mirror the patterns in install.sh. var legacyPatterns = []*regexp.Regexp{ hookLinePattern, aliasesLinePattern, regexp.MustCompile(`^eval\s+["']?\$\((?:[^\s]*/)?netssh\s+alias(?:\s+--shell\s+(?:bash|zsh))?\)["']?\s*(?:#.*)?$`), regexp.MustCompile(`^(?:[^\s]*/)?netssh\s+alias(?:\s+--shell\s+fish)?\s*\|\s*source\s*(?:#.*)?$`), regexp.MustCompile(`^source\s+<\((?:[^\s]*/)?netssh\s+alias\)\s*(?:#.*)?$`), regexp.MustCompile(`^alias\s+[A-Za-z0-9_.-]+(?:=|\s+)["']netssh(?:\s+[^"']+)?["']\s*(?:#.*)?$`), } func isLegacyLine(line string) bool { for _, pattern := range legacyPatterns { if pattern.MatchString(line) { return true } } return false } // HasEntries reports whether the profile contains a managed block or legacy lines. func HasEntries(profilePath string) bool { data, err := os.ReadFile(profilePath) if err != nil { return false } _, removed, err := clean(string(data), profilePath) return removed || err != nil } // 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) } cleaned, removed, err := clean(string(data), profilePath) if err != nil || !removed { return false, err } return true, writeProfile(profilePath, cleaned) } // clean strips managed blocks and legacy lines from profile content. func clean(data, profilePath string) (cleaned string, removed bool, err error) { if data == "" { return "", false, nil } var kept []string inside := false for _, line := range strings.Split(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 isLegacyLine(value) { removed = true continue } kept = append(kept, line) } if inside { return "", false, fmt.Errorf("unclosed netssh block in %s", profilePath) } if !removed { return data, false, nil } cleaned = strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n") // Drop the blank separator line Install puts in front of the block. if strings.HasSuffix(cleaned, "\n\n") { cleaned = strings.TrimRight(cleaned, "\n") + "\n" } return cleaned, true, nil } // writeProfile replaces the profile atomically, following dotfile symlinks // and keeping the permissions of an existing file. func writeProfile(profilePath, content string) error { target, err := filepath.EvalSymlinks(profilePath) if os.IsNotExist(err) { target = profilePath } else if err != nil { return err } mode := os.FileMode(0o644) if info, err := os.Stat(target); err == nil { mode = info.Mode().Perm() } if err := storage.WriteFile(target, []byte(content), mode); err != nil { return fmt.Errorf("writing profile: %w", err) } return nil } // ProfileCandidates lists every shell profile netssh or install.sh may have // written to, in the same order as the installer. func ProfileCandidates() []string { home := os.Getenv("HOME") zdot := zshDir() paths := []string{ filepath.Join(home, ".bashrc"), filepath.Join(home, ".bash_profile"), filepath.Join(home, ".bash_login"), filepath.Join(home, ".profile"), filepath.Join(zdot, ".zshrc"), filepath.Join(zdot, ".zprofile"), filepath.Join(zdot, ".zshenv"), } if zdot != home { paths = append(paths, filepath.Join(home, ".zshrc"), filepath.Join(home, ".zprofile"), filepath.Join(home, ".zshenv")) } return append(paths, filepath.Join(FishConfigDir(), "config.fish")) }