feat: add alias file generation and management for shell hooks
Release / release (push) Successful in 49s

- **Aliases**: Generate shell alias files (`aliases.sh` for bash/zsh, `netssh.fish` for fish) from cached hosts. Regenerate on each shell startup and cache refresh to keep aliases updated.
- **Hooks**: Extend shell hook functionality to include alias file support. Install and uninstall commands updated for bash, zsh, and fish.
- **Tests**: Add unit tests to verify alias file generation, path resolution, and idempotent hook installation.
- **Docs**: Update README with instructions for alias file usage, installation, and relation to hooks.
This commit is contained in:
Sebastian Unterschütz
2026-05-27 23:08:30 +02:00
parent 7c902cab3a
commit fa646f25a6
5 changed files with 450 additions and 58 deletions
+51 -15
View File
@@ -329,7 +329,12 @@ Completions are served from the local cache — no network request on every `<Ta
## Shell Hook
The shell hook runs `netssh shell-init` at the start of every new shell session. It checks whether the cache is older than 24 hours and, if so, starts a background refresh. The check reads a single small file and adds no measurable delay to your shell startup.
`netssh hook install` sets up two things in your shell profile:
1. **`netssh shell-init`** — runs on every new shell session. It regenerates the alias file from the current cache so your aliases are always up to date, and starts a background cache refresh if the last full refresh was more than 24 hours ago.
2. **A `source` line** (bash/zsh only) — loads the generated alias file so shortcuts like `web01` work immediately in every new shell. Fish uses `conf.d/` auto-sourcing instead, so no explicit source line is needed.
The alias file is also generated immediately during `hook install`, so aliases are available as soon as you reload your profile — no `cache refresh` needed first (though the cache must not be empty).
### Install
@@ -340,21 +345,38 @@ netssh hook install --shell zsh
netssh hook install --shell fish
```
This appends exactly one line to your shell profile:
Example output:
```sh
```
Hook installed → /home/user/.bashrc
Reload with: source /home/user/.bashrc
42 aliases written to /home/user/.cache/netssh/aliases.sh
```
What gets written to the profile:
**bash / zsh** (`~/.bashrc` or `~/.zshrc`):
```bash
netssh shell-init # netssh cache auto-refresh
source ~/.cache/netssh/aliases.sh 2>/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
+75 -38
View File
@@ -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
},
}
+82
View File
@@ -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
}
+210
View File
@@ -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")
}
}
+32 -5
View File
@@ -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)