Improve installer updates and dependency checks and add command aliases
Release / release (push) Successful in 7m57s
Release / release (push) Successful in 7m57s
This commit is contained in:
1 parent
12cc64fdd9
commit
4b28d441b0
13 files changed
+973
-148
No files matched your search
+73
-17
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user