Improve installer updates and dependency checks and add command aliases
Release / release (push) Successful in 7m57s

This commit is contained in:
Sebastian Unterschütz committed 2026-10-27 18:10:44 +01:00
1 parent 12cc64fdd9
commit 4b28d441b0
13 files changed
+973 -148

No files matched your search

+73 -17
View File
@@ -4,7 +4,14 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
)
// AliasEntry holds a single shell alias mapping.
@@ -15,9 +22,13 @@ type AliasEntry struct {
// AliasesPath returns the path for the bash/zsh aliases file.
func AliasesPath() string {
cacheDir, err := os.UserCacheDir()
if err != nil {
cacheDir = filepath.Join(os.Getenv("HOME"), ".cache")
cacheDir := os.Getenv("XDG_CACHE_HOME")
if cacheDir == "" {
var err error
cacheDir, err = os.UserCacheDir()
if err != nil {
cacheDir = filepath.Join(os.Getenv("HOME"), ".cache")
}
}
return filepath.Join(cacheDir, "netssh", "aliases.sh")
}
@@ -25,11 +36,7 @@ func AliasesPath() string {
// FishAliasesPath returns the path for the fish conf.d aliases file.
// Fish auto-sources every file in conf.d, so no explicit source line is needed.
func FishAliasesPath() string {
configDir, err := os.UserConfigDir()
if err != nil {
configDir = filepath.Join(os.Getenv("HOME"), ".config")
}
return filepath.Join(configDir, "fish", "conf.d", "netssh.fish")
return filepath.Join(FishConfigDir(), "conf.d", "netssh.fish")
}
// WriteAliasFiles writes alias definitions to disk for all supported shells.
@@ -54,18 +61,14 @@ func writeShAliasFile(entries []AliasEntry) error {
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
for _, e := range entries {
fmt.Fprintf(&b, "alias %s='netssh %s'\n", e.Name, e.Host)
b.WriteString(e.Definition("bash") + "\n")
}
return os.WriteFile(p, []byte(b.String()), 0o644)
return storage.WriteFile(p, []byte(b.String()), 0o600)
}
func writeFishAliasFile(entries []AliasEntry) {
configDir, err := os.UserConfigDir()
if err != nil {
return
}
// Only write if fish is configured on this system.
if _, err := os.Stat(filepath.Join(configDir, "fish")); err != nil {
if _, err := os.Stat(FishConfigDir()); err != nil {
return
}
p := FishAliasesPath()
@@ -76,7 +79,60 @@ func writeFishAliasFile(entries []AliasEntry) {
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
for _, e := range entries {
fmt.Fprintf(&b, "alias %s 'netssh %s'\n", e.Name, e.Host)
b.WriteString(e.Definition("fish") + "\n")
}
os.WriteFile(p, []byte(b.String()), 0o644) //nolint:errcheck
storage.WriteFile(p, []byte(b.String()), 0o600) //nolint:errcheck
}
// Empty Host represents an alias for the netssh command itself.
func (e AliasEntry) Definition(shell string) string {
command := "netssh"
if e.Host != "" {
host := e.Host
if !regexp.MustCompile(`^[a-zA-Z0-9_.:-]+$`).MatchString(host) {
host = shellQuote(host, shell)
}
command += " " + host
}
if shell == "fish" {
return fmt.Sprintf("alias %s %s", e.Name, shellQuote(command, shell))
}
return fmt.Sprintf("alias %s=%s", e.Name, shellQuote(command, shell))
}
func shellQuote(value, shell string) string {
if shell == "fish" {
value = strings.ReplaceAll(value, `\`, `\\`)
return "'" + strings.ReplaceAll(value, "'", `\'`) + "'"
}
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
}
// BuildAliasEntries gives the configured command alias priority over host aliases.
func BuildAliasEntries(entries []cache.Entry, cfg config.Config) []AliasEntry {
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
seen := map[string]bool{"netssh": true}
var out []AliasEntry
if cfg.Shell.Alias != "" && config.ValidateCommandAlias(cfg.Shell.Alias) == nil {
seen[cfg.Shell.Alias] = true
out = append(out, AliasEntry{Name: cfg.Shell.Alias})
}
for _, e := range entries {
name := shortcuts.AliasName(e.Name, cfg.Shortcuts)
if seen[name] || name == "" || !regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_-]*$`).MatchString(name) {
continue
}
seen[name] = true
out = append(out, AliasEntry{Name: name, Host: e.Name})
}
return out
}
// FishConfigDir follows fish's XDG layout on both Linux and macOS.
func FishConfigDir() string {
dir := os.Getenv("XDG_CONFIG_HOME")
if dir == "" {
dir = filepath.Join(os.Getenv("HOME"), ".config")
}
return filepath.Join(dir, "fish")
}
+19
View File
@@ -6,6 +6,8 @@ import (
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
)
@@ -208,3 +210,20 @@ func TestUninstall_AlsoRemovesAliasesSourceLine(t *testing.T) {
t.Error("other content should be preserved")
}
}
func TestCommandAliasGenerationAndCollision(t *testing.T) {
cfg := config.Config{Shell: config.ShellConfig{Alias: "j"}}
entries := hook.BuildAliasEntries([]cache.Entry{{Name: "j"}, {Name: "netssh"}, {Name: "web-01"}}, cfg)
if len(entries) != 2 {
t.Fatalf("entries: %+v", entries)
}
if got := entries[0].Definition("bash"); got != "alias j='netssh'" {
t.Fatal(got)
}
if got := entries[0].Definition("fish"); got != "alias j 'netssh'" {
t.Fatal(got)
}
if got := entries[1].Definition("bash"); got != "alias web-01='netssh web-01'" {
t.Fatal(got)
}
}
+66 -24
View File
@@ -4,12 +4,17 @@ 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"
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"
@@ -25,13 +30,13 @@ func ProfilePath(shell string) (string, error) {
case "bash":
return filepath.Join(os.Getenv("HOME"), ".bashrc"), nil
case "zsh":
return filepath.Join(os.Getenv("HOME"), ".zshrc"), nil
case "fish":
configDir, err := os.UserConfigDir()
if err != nil {
configDir = filepath.Join(os.Getenv("HOME"), ".config")
dir := os.Getenv("ZDOTDIR")
if dir == "" {
dir = os.Getenv("HOME")
}
return filepath.Join(configDir, "fish", "config.fish"), nil
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)
}
@@ -49,7 +54,12 @@ func IsInstalled(profilePath string) bool {
if err != nil {
return false
}
return strings.Contains(string(data), Marker)
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.
@@ -81,23 +91,27 @@ func InstallAliasesSource(profilePath string) (installed bool, err error) {
if err != nil && !os.IsNotExist(err) {
return false, fmt.Errorf("reading profile: %w", err)
}
if strings.Contains(string(data), AliasesMarker) {
return false, nil
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", AliasesSourceLine); err != nil {
if _, err := fmt.Fprintf(f, "%s\n", "source "+shellQuote(AliasesPath(), "bash")+" 2>/dev/null # netssh aliases"); err != nil {
return false, err
}
return true, nil
}
// Uninstall removes the hook line and the aliases source line from the profile.
// Returns (true, nil) when something was removed, (false, nil) when nothing was found.
// Returns (false, nil) — not an error — when the file does not exist.
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) {
@@ -106,22 +120,50 @@ func Uninstall(profilePath string) (removed bool, err error) {
if err != nil {
return false, fmt.Errorf("reading profile: %w", err)
}
content := string(data)
if !strings.Contains(content, Marker) && !strings.Contains(content, AliasesMarker) {
return false, nil
}
var kept []string
for _, line := range strings.Split(content, "\n") {
if strings.Contains(line, Marker) || strings.Contains(line, AliasesMarker) {
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)
}
// Collapse triple blank lines that the removal may leave.
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")
if err := os.WriteFile(profilePath, []byte(cleaned), 0o644); err != nil {
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
+27
View File
@@ -261,3 +261,30 @@ func writeProfile(t *testing.T, content string) string {
}
return f
}
func TestManagedBlockInstallAndUninstall(t *testing.T) {
original := "export KEEP=yes\n# netssh shell-init documentation\necho 'netssh shell-init'\n"
profile := writeProfile(t, original+hook.BlockStart+"\nexport PATH='/custom/bin':\"$PATH\"\n'/custom/bin/netssh' shell-init\n[ ! -f '/custom/cache/netssh/aliases.sh' ] || . '/custom/cache/netssh/aliases.sh'\n"+hook.BlockEnd+"\n")
if installed, err := hook.Install(profile); err != nil || installed {
t.Fatalf("duplicate install: %v, %v", installed, err)
}
if installed, err := hook.InstallAliasesSource(profile); err != nil || installed {
t.Fatalf("duplicate source: %v, %v", installed, err)
}
if removed, err := hook.Uninstall(profile); err != nil || !removed {
t.Fatalf("uninstall: %v, %v", removed, err)
}
data, _ := os.ReadFile(profile)
if string(data) != original {
t.Fatalf("unrelated content changed: %s", data)
}
}
func TestProfilePathRespectsZdotdir(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZDOTDIR", dir)
path, err := hook.ProfilePath("zsh")
if err != nil || path != filepath.Join(dir, ".zshrc") {
t.Fatalf("zsh path: %s, %v", path, err)
}
}