`netssh hook install` and the setup wizard now write the same marked `# >>> netssh >>>` block as install.sh and replace unmarked entries of older versions instead of appending duplicates. Uninstall shares the legacy patterns with the installer (eval/alias/source variants). For zsh the block adds ~/.zfunc to fpath and calls compdef, so completion also works when compinit already ran earlier in .zshrc, e.g. with oh-my-zsh. The installer reports which completion it set up per shell and how to reset ~/.zcompdump. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
251 lines
7.9 KiB
Go
251 lines
7.9 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 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
|
|
}
|
|
|
|
// 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
|
|
}
|