- **Shortcuts**: Add hostname normalization with domain stripping and hyphen folding. Include alias generation for cached hosts. - **Shell Hook**: Automate 24h cache refresh trigger with shell startup hook. Add install/uninstall commands for bash, zsh, and fish. - **Wizard**: Extend setup wizard to configure shortcuts (domains, hyphen stripping) and default SSH port. - **Cache**: Add `GetByShortcut` for resolving hosts via normalized shortcuts. Implement `NeedsRefresh` / `SetRefreshed` logic for refresh timestamps. - **Tests**: Comprehensive unit tests for shortcuts, hook installation, cache refresh, and alias generation. - **Docs**: Update README with shortcuts, shell hook, and default SSH port configuration.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// Marker is the unique string used to detect an existing hook installation.
|
||||
Marker = "netssh shell-init"
|
||||
// Line is the full line written into the shell profile.
|
||||
Line = "netssh shell-init # netssh cache auto-refresh"
|
||||
)
|
||||
|
||||
// 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":
|
||||
return filepath.Join(os.Getenv("HOME"), ".zshrc"), nil
|
||||
case "fish":
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
configDir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
}
|
||||
return filepath.Join(configDir, "fish", "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
|
||||
}
|
||||
return strings.Contains(string(data), Marker)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Uninstall removes the hook line from the profile.
|
||||
// Returns (true, nil) when removed, (false, nil) when the hook was not found.
|
||||
// Returns (false, nil) — not an error — when the file does not exist.
|
||||
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)
|
||||
}
|
||||
content := string(data)
|
||||
if !strings.Contains(content, Marker) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var kept []string
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if strings.Contains(line, Marker) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
// Collapse triple blank lines that the removal may leave.
|
||||
cleaned := strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n")
|
||||
|
||||
if err := os.WriteFile(profilePath, []byte(cleaned), 0o644); err != nil {
|
||||
return false, fmt.Errorf("writing profile: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package hook_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||
)
|
||||
|
||||
// --- ProfilePath ---
|
||||
|
||||
func TestProfilePath_Bash(t *testing.T) {
|
||||
orig := os.Getenv("HOME")
|
||||
os.Setenv("HOME", t.TempDir())
|
||||
defer os.Setenv("HOME", orig)
|
||||
|
||||
p, err := hook.ProfilePath("bash")
|
||||
if err != nil {
|
||||
t.Fatalf("ProfilePath(bash): %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(p, ".bashrc") {
|
||||
t.Errorf("bash profile should end with .bashrc, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfilePath_Zsh(t *testing.T) {
|
||||
orig := os.Getenv("HOME")
|
||||
os.Setenv("HOME", t.TempDir())
|
||||
defer os.Setenv("HOME", orig)
|
||||
|
||||
p, err := hook.ProfilePath("zsh")
|
||||
if err != nil {
|
||||
t.Fatalf("ProfilePath(zsh): %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(p, ".zshrc") {
|
||||
t.Errorf("zsh profile should end with .zshrc, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfilePath_Fish(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
p, err := hook.ProfilePath("fish")
|
||||
if err != nil {
|
||||
t.Fatalf("ProfilePath(fish): %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(p, "config.fish") {
|
||||
t.Errorf("fish profile should end with config.fish, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfilePath_UnknownShell(t *testing.T) {
|
||||
if _, err := hook.ProfilePath("ksh"); err == nil {
|
||||
t.Error("unknown shell should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
// --- IsInstalled ---
|
||||
|
||||
func TestIsInstalled_Present(t *testing.T) {
|
||||
profile := writeProfile(t, hook.Line+"\n")
|
||||
if !hook.IsInstalled(profile) {
|
||||
t.Error("IsInstalled should return true when marker is present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInstalled_Absent(t *testing.T) {
|
||||
profile := writeProfile(t, "export PATH=$PATH\n")
|
||||
if hook.IsInstalled(profile) {
|
||||
t.Error("IsInstalled should return false when marker is absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInstalled_MissingFile(t *testing.T) {
|
||||
if hook.IsInstalled("/nonexistent/path/.bashrc") {
|
||||
t.Error("IsInstalled should return false for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Install ---
|
||||
|
||||
func TestInstall_Fresh(t *testing.T) {
|
||||
profile := filepath.Join(t.TempDir(), ".bashrc")
|
||||
|
||||
installed, err := hook.Install(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if !installed {
|
||||
t.Error("should report installed=true on fresh install")
|
||||
}
|
||||
if !hook.IsInstalled(profile) {
|
||||
t.Error("profile should contain the hook line after Install")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstall_Idempotent(t *testing.T) {
|
||||
profile := filepath.Join(t.TempDir(), ".bashrc")
|
||||
|
||||
hook.Install(profile)
|
||||
installed, err := hook.Install(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("second Install: %v", err)
|
||||
}
|
||||
if installed {
|
||||
t.Error("second Install should report installed=false")
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
if count := strings.Count(string(data), hook.Marker); count != 1 {
|
||||
t.Errorf("hook line should appear exactly once, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstall_CreatesProfileAndParentDirs(t *testing.T) {
|
||||
profile := filepath.Join(t.TempDir(), "nested", "dir", ".zshrc")
|
||||
|
||||
if _, err := hook.Install(profile); err != nil {
|
||||
t.Fatalf("Install should create missing directories: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(profile); err != nil {
|
||||
t.Error("profile file should have been created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstall_PreservesExistingContent(t *testing.T) {
|
||||
profile := writeProfile(t, "export PATH=$PATH:/usr/local/bin\n")
|
||||
|
||||
hook.Install(profile)
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "export PATH") {
|
||||
t.Error("Install should not remove existing content")
|
||||
}
|
||||
if !strings.Contains(content, hook.Marker) {
|
||||
t.Error("hook line should be appended")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstall_AppendedAtEnd(t *testing.T) {
|
||||
profile := writeProfile(t, "existing line\n")
|
||||
hook.Install(profile)
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
last := lines[len(lines)-1]
|
||||
if last != hook.Line {
|
||||
t.Errorf("hook line should be the last line, got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Uninstall ---
|
||||
|
||||
func TestUninstall_Removes(t *testing.T) {
|
||||
profile := writeProfile(t, "export PATH=$PATH\n"+hook.Line+"\nexport FOO=bar\n")
|
||||
|
||||
removed, err := hook.Uninstall(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
if !removed {
|
||||
t.Error("should report removed=true")
|
||||
}
|
||||
if hook.IsInstalled(profile) {
|
||||
t.Error("hook line should have been removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstall_PreservesOtherContent(t *testing.T) {
|
||||
profile := writeProfile(t, "export FOO=bar\n"+hook.Line+"\nexport BAZ=qux\n")
|
||||
|
||||
hook.Uninstall(profile)
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "export FOO=bar") {
|
||||
t.Error("content before hook should be preserved")
|
||||
}
|
||||
if !strings.Contains(content, "export BAZ=qux") {
|
||||
t.Error("content after hook should be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstall_NotPresent(t *testing.T) {
|
||||
profile := writeProfile(t, "export PATH=$PATH\n")
|
||||
|
||||
removed, err := hook.Uninstall(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
if removed {
|
||||
t.Error("should report removed=false when hook was not present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstall_MissingFile(t *testing.T) {
|
||||
removed, err := hook.Uninstall("/nonexistent/.bashrc")
|
||||
if err != nil {
|
||||
t.Errorf("Uninstall on missing file should not error: %v", err)
|
||||
}
|
||||
if removed {
|
||||
t.Error("should report removed=false for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstall_CollapsesExtraBlankLines(t *testing.T) {
|
||||
// blank line before hook + hook + blank line after = three consecutive newlines after removal
|
||||
profile := writeProfile(t, "line1\n\n"+hook.Line+"\n\nline2\n")
|
||||
|
||||
hook.Uninstall(profile)
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
if strings.Contains(string(data), "\n\n\n") {
|
||||
t.Error("three consecutive blank lines should be collapsed after uninstall")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Roundtrip ---
|
||||
|
||||
func TestInstallUninstall_Roundtrip(t *testing.T) {
|
||||
profile := writeProfile(t, "existing content\n")
|
||||
|
||||
hook.Install(profile)
|
||||
if !hook.IsInstalled(profile) {
|
||||
t.Fatal("should be installed after Install")
|
||||
}
|
||||
|
||||
hook.Uninstall(profile)
|
||||
if hook.IsInstalled(profile) {
|
||||
t.Fatal("should not be installed after Uninstall")
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
if !strings.Contains(string(data), "existing content") {
|
||||
t.Error("original content should survive install+uninstall cycle")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ReloadNote ---
|
||||
|
||||
func TestReloadNote_ContainsPath(t *testing.T) {
|
||||
note := hook.ReloadNote("/home/user/.bashrc")
|
||||
if !strings.Contains(note, "/home/user/.bashrc") {
|
||||
t.Errorf("ReloadNote should contain the profile path, got %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func writeProfile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
f := filepath.Join(t.TempDir(), "profile")
|
||||
if err := os.WriteFile(f, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
Reference in New Issue
Block a user