diff --git a/README.md b/README.md index 799cf62..92dfc7d 100644 --- a/README.md +++ b/README.md @@ -329,7 +329,12 @@ Completions are served from the local cache — no network request on every `/dev/null # netssh aliases +``` + +**fish** (`~/.config/fish/config.fish`): +```fish netssh shell-init # netssh cache auto-refresh ``` -| Shell | Profile file | -|-------|-------------| -| bash | `~/.bashrc` | -| zsh | `~/.zshrc` | -| fish | `~/.config/fish/config.fish` | +Fish alias file (`~/.config/fish/conf.d/netssh.fish`) is written automatically and sourced by fish on every shell start without any profile changes. -The install is idempotent — running it again does nothing if the hook is already present. +| Shell | Profile file | Alias file | +|-------|-------------|------------| +| bash | `~/.bashrc` | `~/.cache/netssh/aliases.sh` | +| zsh | `~/.zshrc` | `~/.cache/netssh/aliases.sh` | +| fish | `~/.config/fish/config.fish` | `~/.config/fish/conf.d/netssh.fish` | -Reload your profile after installation: +The install is idempotent — running it again changes nothing if the lines are already present. + +Reload your profile after installation to activate immediately: ```sh source ~/.bashrc # bash @@ -362,6 +384,18 @@ source ~/.zshrc # zsh source ~/.config/fish/config.fish # fish ``` +### Alias file updates + +The alias file is regenerated automatically — no manual action needed: + +| Event | Aliases updated | +|-------|----------------| +| New shell session (`shell-init`) | Yes — from the current local cache | +| `netssh cache refresh` | Yes — after the full refresh completes | +| Background 24h auto-refresh | Yes — after the background refresh completes | + +Aliases for newly added hosts appear in the next shell session after a `cache refresh` has run. + ### Uninstall ```sh @@ -369,16 +403,18 @@ netssh hook uninstall # auto-detects $SHELL netssh hook uninstall --shell zsh ``` -Removes the `netssh shell-init` line from the profile and collapses any blank lines left behind. +Removes both the `netssh shell-init` line and the `source aliases.sh` line from the profile. The alias files (`aliases.sh`, `netssh.fish`) are left on disk — delete them manually if desired. -### How it differs from the connect-time trigger +### Relation to the connect-time auto-refresh + +The shell hook and the connect-time trigger (which fires on each `netssh` invocation) are independent but complementary: | Trigger | When it fires | |---------|--------------| -| Connect / TUI start | On the next SSH command or `netssh` TUI after 24 h | -| Shell hook | On the first new shell session after 24 h | +| SSH connect / TUI start | On the next `netssh` call after 24 h have elapsed | +| Shell hook (`shell-init`) | On the first new shell session after 24 h have elapsed | -Both triggers are non-blocking: the refresh runs in the background and your SSH connection (or prompt) is not delayed. You can install both — they share the same `~/.cache/netssh/last_refresh` timestamp, so the background process runs at most once per 24 hours regardless of how many shells or connections you open. +Both are non-blocking — the refresh runs as a background process and never delays your prompt or SSH connection. They share `~/.cache/netssh/last_refresh`, so only one background refresh runs per 24-hour window regardless of how many shells or connections are opened. ## Development diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index cdc084f..5689964 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -521,6 +521,11 @@ func cacheRefreshCmd() *cobra.Command { if err := c.SetRefreshed(); err != nil { fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err) } + if aliasEntries := buildAliasEntries(c, *cfg); len(aliasEntries) > 0 { + if err := hook.WriteAliasFiles(aliasEntries); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err) + } + } fmt.Printf("%d entries written to cache.\n", len(entries)) return nil }, @@ -530,6 +535,27 @@ func cacheRefreshCmd() *cobra.Command { return cmd } +// buildAliasEntries computes the sorted, deduplicated alias list from the cache. +func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry { + all := c.All() + sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name }) + + seen := make(map[string]string) + var out []hook.AliasEntry + for _, e := range all { + name := shortcuts.AliasName(e.Name, cfg.Shortcuts) + if name == "" { + continue + } + if _, dup := seen[name]; dup { + continue + } + seen[name] = e.Name + out = append(out, hook.AliasEntry{Name: name, Host: e.Name}) + } + return out +} + func aliasCmd() *cobra.Command { var shell string cmd := &cobra.Command{ @@ -558,46 +584,23 @@ Or use in a script: shell = filepath.Base(os.Getenv("SHELL")) } - entries := c.All() - sort.Slice(entries, func(i, j int) bool { - return entries[i].Name < entries[j].Name - }) - - // Deduplicate: first host wins when two normalize to the same alias. - seen := make(map[string]string) // alias name → canonical name - var lines []string - for _, e := range entries { - aliasName := shortcuts.AliasName(e.Name, cfg.Shortcuts) - if aliasName == "" { - continue - } - if prev, exists := seen[aliasName]; exists { - fmt.Fprintf(os.Stderr, "netssh: alias %q conflict: %s and %s — skipping %s\n", aliasName, prev, e.Name, e.Name) - continue - } - seen[aliasName] = e.Name - - switch shell { - case "fish": - lines = append(lines, fmt.Sprintf("alias %s 'netssh %s'", aliasName, e.Name)) - default: - lines = append(lines, fmt.Sprintf("alias %s='netssh %s'", aliasName, e.Name)) - } - } - - if len(lines) == 0 { + entries := buildAliasEntries(c, *cfg) + if len(entries) == 0 { fmt.Fprintln(os.Stderr, "netssh: cache is empty — run 'netssh cache refresh' first") return nil } switch shell { case "fish": - fmt.Printf("# netssh aliases (%d hosts) — source with: netssh alias --shell fish | source\n", len(lines)) + fmt.Printf("# netssh aliases (%d hosts) — source with: netssh alias --shell fish | source\n", len(entries)) + for _, e := range entries { + fmt.Printf("alias %s 'netssh %s'\n", e.Name, e.Host) + } default: - fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(lines)) - } - for _, l := range lines { - fmt.Println(l) + fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(entries)) + for _, e := range entries { + fmt.Printf("alias %s='netssh %s'\n", e.Name, e.Host) + } } return nil }, @@ -606,12 +609,12 @@ Or use in a script: return cmd } -// shellInitCmd is called at shell startup to trigger a background cache refresh when stale. -// It is intentionally silent — no output on success so it never disrupts shell startup. +// shellInitCmd is called at shell startup to update alias files and trigger a +// background cache refresh when stale. Intentionally silent — never disrupts the prompt. func shellInitCmd() *cobra.Command { return &cobra.Command{ Use: "shell-init", - Short: "Trigger a background cache refresh if stale (add to shell profile via 'hook install')", + Short: "Regenerate alias files and trigger a background cache refresh if stale", Hidden: true, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() @@ -619,6 +622,14 @@ func shellInitCmd() *cobra.Command { return nil } c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) + _ = c.Load() + + // Regenerate alias files from the current cache every shell start + // so aliases are always up-to-date even before a refresh completes. + if entries := buildAliasEntries(c, *cfg); len(entries) > 0 { + _ = hook.WriteAliasFiles(entries) + } + maybeBackgroundRefresh(cfg, c) return nil }, @@ -652,15 +663,41 @@ After installation, reload your profile or open a new shell.`, if err != nil { return err } + installed, err := hook.Install(profile) if err != nil { return err } + // For bash/zsh: also add the source line so aliases are loaded on startup. + // Fish uses conf.d auto-sourcing — no explicit source line needed. + if shell != "fish" { + if _, err := hook.InstallAliasesSource(profile); err != nil { + return err + } + } + if !installed { fmt.Printf("Hook already installed in %s\n", profile) - return nil + } else { + fmt.Printf("Hook installed → %s\n%s\n", profile, hook.ReloadNote(profile)) + } + + // Generate the aliases file immediately so it is ready on the next shell start. + cfg, cfgErr := config.Load() + if cfgErr == nil && cfg.NetBox.URL != "" { + c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) + if _ = c.Load(); true { + if entries := buildAliasEntries(c, *cfg); len(entries) > 0 { + if err := hook.WriteAliasFiles(entries); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err) + } else { + fmt.Printf("%d aliases written to %s\n", len(entries), hook.AliasesPath()) + } + } else { + fmt.Println("Cache is empty — run 'netssh cache refresh' to populate aliases.") + } + } } - fmt.Printf("Hook installed → %s\n%s\n", profile, hook.ReloadNote(profile)) return nil }, } diff --git a/internal/hook/aliases.go b/internal/hook/aliases.go new file mode 100644 index 0000000..05767e3 --- /dev/null +++ b/internal/hook/aliases.go @@ -0,0 +1,82 @@ +package hook + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// AliasEntry holds a single shell alias mapping. +type AliasEntry struct { + Name string // short alias name (e.g. "web01") + Host string // canonical NetBox hostname (e.g. "web01.example.com") +} + +// 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") + } + return filepath.Join(cacheDir, "netssh", "aliases.sh") +} + +// 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") +} + +// WriteAliasFiles writes alias definitions to disk for all supported shells. +// +// The bash/zsh file (~/.cache/netssh/aliases.sh) is always written. +// The fish file (~/.config/fish/conf.d/netssh.fish) is written only when +// ~/.config/fish/ already exists (i.e. fish is configured on this system). +func WriteAliasFiles(entries []AliasEntry) error { + if err := writeShAliasFile(entries); err != nil { + return err + } + writeFishAliasFile(entries) // best-effort — never blocks the caller on failure + return nil +} + +func writeShAliasFile(entries []AliasEntry) error { + p := AliasesPath() + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return fmt.Errorf("creating aliases dir: %w", err) + } + var b strings.Builder + 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) + } + return os.WriteFile(p, []byte(b.String()), 0o644) +} + +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 { + return + } + p := FishAliasesPath() + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return + } + var b strings.Builder + 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) + } + os.WriteFile(p, []byte(b.String()), 0o644) //nolint:errcheck +} diff --git a/internal/hook/aliases_test.go b/internal/hook/aliases_test.go new file mode 100644 index 0000000..c067076 --- /dev/null +++ b/internal/hook/aliases_test.go @@ -0,0 +1,210 @@ +package hook_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook" +) + +func TestAliasesPath_UnderCacheDir(t *testing.T) { + dir := t.TempDir() + orig := os.Getenv("XDG_CACHE_HOME") + os.Setenv("XDG_CACHE_HOME", dir) + defer os.Setenv("XDG_CACHE_HOME", orig) + + p := hook.AliasesPath() + if !strings.HasPrefix(p, dir) { + t.Errorf("AliasesPath should be under XDG_CACHE_HOME, got %q", p) + } + if !strings.HasSuffix(p, "aliases.sh") { + t.Errorf("AliasesPath should end with aliases.sh, got %q", p) + } +} + +func TestFishAliasesPath_UnderConfigDir(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 := hook.FishAliasesPath() + if !strings.HasPrefix(p, dir) { + t.Errorf("FishAliasesPath should be under XDG_CONFIG_HOME, got %q", p) + } + if !strings.HasSuffix(p, "netssh.fish") { + t.Errorf("FishAliasesPath should end with netssh.fish, got %q", p) + } +} + +// TestWriteAliasFiles_ShFile verifies the bash/zsh file is always written. +func TestWriteAliasFiles_ShFile(t *testing.T) { + cacheDir := t.TempDir() + orig := os.Getenv("XDG_CACHE_HOME") + os.Setenv("XDG_CACHE_HOME", cacheDir) + defer os.Setenv("XDG_CACHE_HOME", orig) + + entries := []hook.AliasEntry{ + {Name: "web01", Host: "web01.example.com"}, + {Name: "db01", Host: "db01.example.com"}, + } + if err := hook.WriteAliasFiles(entries); err != nil { + t.Fatalf("WriteAliasFiles: %v", err) + } + + data, err := os.ReadFile(hook.AliasesPath()) + if err != nil { + t.Fatalf("aliases.sh not created: %v", err) + } + content := string(data) + + for _, want := range []string{ + "alias web01='netssh web01.example.com'", + "alias db01='netssh db01.example.com'", + } { + if !strings.Contains(content, want) { + t.Errorf("aliases.sh missing %q\ncontent:\n%s", want, content) + } + } +} + +// TestWriteAliasFiles_FishFile verifies the fish file is written when fish config dir exists. +func TestWriteAliasFiles_FishFile(t *testing.T) { + configDir := t.TempDir() + orig := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", configDir) + defer os.Setenv("XDG_CONFIG_HOME", orig) + + // Create the fish config directory so WriteAliasFiles knows fish is set up. + fishDir := filepath.Join(configDir, "fish") + os.MkdirAll(fishDir, 0o755) + + cacheDir := t.TempDir() + origCache := os.Getenv("XDG_CACHE_HOME") + os.Setenv("XDG_CACHE_HOME", cacheDir) + defer os.Setenv("XDG_CACHE_HOME", origCache) + + entries := []hook.AliasEntry{{Name: "web01", Host: "web01.example.com"}} + if err := hook.WriteAliasFiles(entries); err != nil { + t.Fatalf("WriteAliasFiles: %v", err) + } + + data, err := os.ReadFile(hook.FishAliasesPath()) + if err != nil { + t.Fatalf("netssh.fish not created: %v", err) + } + if !strings.Contains(string(data), "alias web01 'netssh web01.example.com'") { + t.Errorf("fish alias file missing expected line\ncontent:\n%s", string(data)) + } +} + +// TestWriteAliasFiles_SkipsFishWhenNotConfigured verifies that fish file is NOT written +// when fish is not set up (no ~/.config/fish directory). +func TestWriteAliasFiles_SkipsFishWhenNotConfigured(t *testing.T) { + configDir := t.TempDir() // no fish subdir + orig := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", configDir) + defer os.Setenv("XDG_CONFIG_HOME", orig) + + cacheDir := t.TempDir() + origCache := os.Getenv("XDG_CACHE_HOME") + os.Setenv("XDG_CACHE_HOME", cacheDir) + defer os.Setenv("XDG_CACHE_HOME", origCache) + + entries := []hook.AliasEntry{{Name: "web01", Host: "web01.example.com"}} + if err := hook.WriteAliasFiles(entries); err != nil { + t.Fatalf("WriteAliasFiles: %v", err) + } + + if _, err := os.Stat(hook.FishAliasesPath()); err == nil { + t.Error("fish alias file should not be created when fish is not configured") + } +} + +// TestWriteAliasFiles_EmptyEntries verifies an empty (but valid) file is written. +func TestWriteAliasFiles_EmptyEntries(t *testing.T) { + cacheDir := t.TempDir() + orig := os.Getenv("XDG_CACHE_HOME") + os.Setenv("XDG_CACHE_HOME", cacheDir) + defer os.Setenv("XDG_CACHE_HOME", orig) + + if err := hook.WriteAliasFiles(nil); err != nil { + t.Fatalf("WriteAliasFiles with nil entries: %v", err) + } + data, err := os.ReadFile(hook.AliasesPath()) + if err != nil { + t.Fatalf("aliases.sh not created: %v", err) + } + // Should contain only the header comment, no alias lines. + if strings.Contains(string(data), "alias ") { + t.Error("aliases.sh should have no alias lines for empty entries") + } +} + +// --- InstallAliasesSource --- + +func TestInstallAliasesSource_Fresh(t *testing.T) { + profile := writeProfile(t, "existing line\n") + + installed, err := hook.InstallAliasesSource(profile) + if err != nil { + t.Fatalf("InstallAliasesSource: %v", err) + } + if !installed { + t.Error("should report installed=true on first install") + } + + data, _ := os.ReadFile(profile) + if !strings.Contains(string(data), hook.AliasesMarker) { + t.Error("profile should contain aliases source line") + } +} + +func TestInstallAliasesSource_Idempotent(t *testing.T) { + profile := writeProfile(t, "") + + hook.InstallAliasesSource(profile) + installed, err := hook.InstallAliasesSource(profile) + if err != nil { + t.Fatalf("second InstallAliasesSource: %v", err) + } + if installed { + t.Error("second call should report installed=false") + } + + data, _ := os.ReadFile(profile) + if count := strings.Count(string(data), hook.AliasesMarker); count != 1 { + t.Errorf("aliases source line should appear once, got %d", count) + } +} + +// TestUninstall_AlsoRemovesAliasesSourceLine verifies that Uninstall removes both lines. +func TestUninstall_AlsoRemovesAliasesSourceLine(t *testing.T) { + content := "export PATH=$PATH\n" + + hook.Line + "\n" + + hook.AliasesSourceLine + "\n" + + "export FOO=bar\n" + profile := writeProfile(t, content) + + removed, err := hook.Uninstall(profile) + if err != nil { + t.Fatalf("Uninstall: %v", err) + } + if !removed { + t.Error("should report removed=true") + } + + data, _ := os.ReadFile(profile) + s := string(data) + if strings.Contains(s, hook.Marker) { + t.Error("shell-init line should be removed") + } + if strings.Contains(s, hook.AliasesMarker) { + t.Error("aliases source line should be removed") + } + if !strings.Contains(s, "export PATH") || !strings.Contains(s, "export FOO") { + t.Error("other content should be preserved") + } +} diff --git a/internal/hook/hook.go b/internal/hook/hook.go index bb973cc..6829af3 100644 --- a/internal/hook/hook.go +++ b/internal/hook/hook.go @@ -8,10 +8,15 @@ import ( ) const ( - // Marker is the unique string used to detect an existing hook installation. + // Marker is the unique string used to detect an existing hook line. Marker = "netssh shell-init" // 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. @@ -68,8 +73,30 @@ func Install(profilePath string) (installed bool, err error) { return true, nil } -// Uninstall removes the hook line from the profile. -// Returns (true, nil) when removed, (false, nil) when the hook was not found. +// 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) + } + if strings.Contains(string(data), AliasesMarker) { + 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 { + 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. func Uninstall(profilePath string) (removed bool, err error) { data, err := os.ReadFile(profilePath) @@ -80,13 +107,13 @@ func Uninstall(profilePath string) (removed bool, err error) { return false, fmt.Errorf("reading profile: %w", err) } content := string(data) - if !strings.Contains(content, Marker) { + 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) { + if strings.Contains(line, Marker) || strings.Contains(line, AliasesMarker) { continue } kept = append(kept, line)