feat: introduce shortcuts and shell hook support
Release / release (push) Successful in 50s

- **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:
Sebastian Unterschütz
2026-05-27 22:53:24 +02:00
parent d127a3b957
commit 7c902cab3a
13 changed files with 1378 additions and 19 deletions
+158
View File
@@ -300,6 +300,164 @@ func TestMarkUsed_RoundtripViaSave(t *testing.T) {
}
}
// --- GetByShortcut tests ---
func TestGetByShortcut_MatchFresh(t *testing.T) {
c := New("", 3600)
c.Upsert(Entry{Name: "web01.example.com", IP: "10.0.0.1", Kind: "device"})
normalize := func(s string) string {
// strip .example.com
if len(s) > len(".example.com") && s[len(s)-len(".example.com"):] == ".example.com" {
s = s[:len(s)-len(".example.com")]
}
return s
}
e, found, fresh := c.GetByShortcut("web01", normalize)
if !found {
t.Fatal("expected entry to be found")
}
if !fresh {
t.Error("entry just inserted should be fresh")
}
if e.IP != "10.0.0.1" {
t.Errorf("IP: got %q, want %q", e.IP, "10.0.0.1")
}
}
func TestGetByShortcut_MatchStale(t *testing.T) {
c := New("", 1) // 1 second TTL
stale := Entry{Name: "web01.example.com", IP: "10.0.0.1", Kind: "device", CachedAt: time.Now().Add(-2 * time.Second)}
c.mu.Lock()
c.entries["web01.example.com"] = stale
c.mu.Unlock()
normalize := func(s string) string {
if len(s) > len(".example.com") && s[len(s)-len(".example.com"):] == ".example.com" {
s = s[:len(s)-len(".example.com")]
}
return s
}
_, found, fresh := c.GetByShortcut("web01", normalize)
if !found {
t.Fatal("expected entry to be found even when stale")
}
if fresh {
t.Error("entry older than TTL should not be fresh")
}
}
func TestGetByShortcut_NotFound(t *testing.T) {
c := New("", 3600)
c.Upsert(Entry{Name: "db01.example.com", IP: "10.0.0.2", Kind: "device"})
_, found, _ := c.GetByShortcut("web01", func(s string) string { return s })
if found {
t.Error("should not find an entry that does not match the shortcut")
}
}
func TestGetByShortcut_EmptyCache(t *testing.T) {
c := New("", 3600)
_, found, _ := c.GetByShortcut("web01", func(s string) string { return s })
if found {
t.Error("empty cache should return found=false")
}
}
func TestGetByShortcut_MultiDomain(t *testing.T) {
c := New("", 3600)
// Entry uses second domain (.example.de)
c.Upsert(Entry{Name: "web01.example.de", IP: "10.0.0.5", Kind: "vm"})
normalize := func(s string) string {
for _, suffix := range []string{".example.com", ".example.de"} {
if len(s) > len(suffix) && s[len(s)-len(suffix):] == suffix {
return s[:len(s)-len(suffix)]
}
}
return s
}
e, found, _ := c.GetByShortcut("web01", normalize)
if !found {
t.Fatal("should match entry with second configured domain")
}
if e.IP != "10.0.0.5" {
t.Errorf("IP: got %q, want %q", e.IP, "10.0.0.5")
}
}
// --- NeedsRefresh / SetRefreshed tests ---
// These tests require NeedsRefresh(time.Duration) bool and SetRefreshed() error
// to be implemented on *Cache. The refreshStamp field (path to the timestamp file)
// must be set before calling these methods.
func TestNeedsRefresh_NeverRefreshed(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if !c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh should return true when last_refresh file does not exist")
}
}
func TestNeedsRefresh_JustRefreshed(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if err := c.SetRefreshed(); err != nil {
t.Fatalf("SetRefreshed: %v", err)
}
if c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh should return false immediately after SetRefreshed")
}
}
func TestNeedsRefresh_Stale(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
// Write a timestamp 25 hours ago
oldTime := time.Now().Add(-25 * time.Hour).Format(time.RFC3339)
if err := os.WriteFile(stampPath, []byte(oldTime), 0o644); err != nil {
t.Fatalf("writing stamp: %v", err)
}
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if !c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh should return true when last_refresh is older than duration")
}
}
func TestSetRefreshed_Roundtrip(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if err := c.SetRefreshed(); err != nil {
t.Fatalf("SetRefreshed: %v", err)
}
// Just refreshed: 24h window → not stale
if c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh(24h) should be false right after SetRefreshed")
}
// Zero duration: everything is stale
if !c.NeedsRefresh(0) {
t.Error("NeedsRefresh(0) should always return true")
}
}
// tempFile writes content to a temp file and returns its path.
func tempFile(t *testing.T, content []byte) string {
t.Helper()