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
+253 -5
View File
@@ -4,8 +4,10 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"text/tabwriter"
"time"
@@ -15,9 +17,11 @@ import (
"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"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
)
@@ -27,6 +31,9 @@ var managedSubcommands = map[string]bool{
"configure": true,
"search": true,
"cache": true,
"alias": true,
"hook": true,
"shell-init": true,
"completion": true,
"__complete": true,
"help": true,
@@ -87,9 +94,15 @@ func runSSHWrapper(args []string) {
args = append([]string{"-l", cfg.SSH.DefaultUser}, args...)
parsed.DestIdx += 2
}
// Inject the configured default port if none was given on the command line.
if cfg.SSH.DefaultPort > 0 && !internalssh.HasPortFlag(args) {
args = append([]string{"-p", strconv.Itoa(cfg.SSH.DefaultPort)}, args...)
parsed.DestIdx += 2
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
_ = c.Load()
maybeBackgroundRefresh(cfg, c)
// Cache hit with a fresh TTL — connect directly without querying NetBox.
if entry, fresh := c.Get(parsed.Host); fresh {
@@ -99,6 +112,24 @@ func runSSHWrapper(args []string) {
return
}
// Shortcut cache lookup: scan entries whose normalized name matches the input.
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
lookupHost := parsed.Host
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
if shortcutsEnabled {
normalize := shortcuts.MakeNormalizer(cfg.Shortcuts)
if entry, found, fresh := c.GetByShortcut(lookupHost, normalize); found {
if fresh {
c.MarkUsed(entry.Name)
_ = c.Save()
connect(entry.IP, parsed, args)
return
}
// Stale shortcut match — use canonical name for NetBox re-fetch.
lookupHost = entry.Name
}
}
if cfg.NetBox.URL == "" {
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
}
@@ -107,18 +138,40 @@ func runSSHWrapper(args []string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
entries, err := nbClient.Search(ctx, parsed.Host, netbox.SearchOptions{})
entries, err := nbClient.Search(ctx, lookupHost, netbox.SearchOptions{})
if err != nil {
fatalf("NetBox search failed: %v", err)
}
var target *netbox.HostEntry
for i, e := range entries {
if strings.EqualFold(e.Name, parsed.Host) {
if strings.EqualFold(e.Name, lookupHost) {
target = &entries[i]
break
}
}
// Domain expansion: if still no match and the input has no dots, try appending
// each configured domain suffix and re-querying NetBox.
if target == nil && !strings.Contains(parsed.Host, ".") && len(cfg.Shortcuts.Domains) > 0 {
for _, domain := range cfg.Shortcuts.Domains {
expanded := parsed.Host + domain
expandedEntries, searchErr := nbClient.Search(ctx, expanded, netbox.SearchOptions{})
if searchErr != nil {
continue
}
for i, e := range expandedEntries {
if strings.EqualFold(e.Name, expanded) {
target = &expandedEntries[i]
break
}
}
if target != nil {
break
}
}
}
if target == nil {
fatalf("host %q not found in NetBox", parsed.Host)
}
@@ -153,6 +206,7 @@ func runTUI() {
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
_ = c.Load()
maybeBackgroundRefresh(cfg, c)
var nbClient *netbox.Client
if cfg.NetBox.URL != "" {
@@ -188,8 +242,12 @@ func runTUI() {
sshArgs = append(sshArgs, "-l", user)
}
if host.Port != "" {
sshArgs = append(sshArgs, "-p", host.Port)
port := host.Port
if port == "" && cfg.SSH.DefaultPort > 0 {
port = strconv.Itoa(cfg.SSH.DefaultPort)
}
if port != "" {
sshArgs = append(sshArgs, "-p", port)
}
sshArgs = append(sshArgs, host.IP)
@@ -231,7 +289,7 @@ func rootCmd() *cobra.Command {
},
}
root.AddCommand(configureCmd(), searchCmd(), cacheCmd())
root.AddCommand(configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd())
// cobra builds the "completion" command lazily; force init so we can extend it.
root.InitDefaultCompletionCmd()
@@ -460,6 +518,9 @@ func cacheRefreshCmd() *cobra.Command {
if err := c.Save(); err != nil {
return err
}
if err := c.SetRefreshed(); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
}
fmt.Printf("%d entries written to cache.\n", len(entries))
return nil
},
@@ -469,6 +530,193 @@ func cacheRefreshCmd() *cobra.Command {
return cmd
}
func aliasCmd() *cobra.Command {
var shell string
cmd := &cobra.Command{
Use: "alias",
Short: "Print shell aliases for all cached hosts",
Long: `Print shell alias definitions for all cached hosts.
The alias name is the shortened form derived from the configured shortcuts
(domain suffixes stripped, hyphens optionally stripped).
Source the output in your shell profile:
bash/zsh: eval "$(netssh alias)"
fish: netssh alias --shell fish | source
Or use in a script:
source <(netssh alias)`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
if err := c.Load(); err != nil {
return err
}
if shell == "" {
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 {
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))
default:
fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(lines))
}
for _, l := range lines {
fmt.Println(l)
}
return nil
},
}
cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)")
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.
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')",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil || cfg.NetBox.URL == "" {
return nil
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
maybeBackgroundRefresh(cfg, c)
return nil
},
}
}
func hookCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "hook",
Short: "Manage shell hooks for automatic cache refresh at shell startup",
}
cmd.AddCommand(hookInstallCmd(), hookUninstallCmd())
return cmd
}
func hookInstallCmd() *cobra.Command {
var shell string
cmd := &cobra.Command{
Use: "install",
Short: "Add netssh shell-init to your shell profile",
Long: `Appends a single line to your shell profile that runs 'netssh shell-init'
on every new shell session. shell-init checks whether the cache is older
than 24 hours and, if so, starts a background refresh — no delay to your prompt.
After installation, reload your profile or open a new shell.`,
RunE: func(cmd *cobra.Command, args []string) error {
if shell == "" {
shell = filepath.Base(os.Getenv("SHELL"))
}
profile, err := hook.ProfilePath(shell)
if err != nil {
return err
}
installed, err := hook.Install(profile)
if err != nil {
return err
}
if !installed {
fmt.Printf("Hook already installed in %s\n", profile)
return nil
}
fmt.Printf("Hook installed → %s\n%s\n", profile, hook.ReloadNote(profile))
return nil
},
}
cmd.Flags().StringVar(&shell, "shell", "", "Shell to install for (default: $SHELL). Supported: bash, zsh, fish")
return cmd
}
func hookUninstallCmd() *cobra.Command {
var shell string
cmd := &cobra.Command{
Use: "uninstall",
Short: "Remove netssh shell-init from your shell profile",
RunE: func(cmd *cobra.Command, args []string) error {
if shell == "" {
shell = filepath.Base(os.Getenv("SHELL"))
}
profile, err := hook.ProfilePath(shell)
if err != nil {
return err
}
removed, err := hook.Uninstall(profile)
if err != nil {
return err
}
if !removed {
fmt.Printf("No hook found in %s\n", profile)
return nil
}
fmt.Printf("Hook removed from %s\n", profile)
return nil
},
}
cmd.Flags().StringVar(&shell, "shell", "", "Shell to uninstall from (default: $SHELL). Supported: bash, zsh, fish")
return cmd
}
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
// has not been fully refreshed within the last 24 hours.
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
if cfg.NetBox.URL == "" {
return
}
if !c.NeedsRefresh(24 * time.Hour) {
return
}
self, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(self, "cache", "refresh")
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Stdin = nil
_ = cmd.Start() // fire and forget — becomes orphan after parent execs ssh
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "netssh: "+format+"\n", args...)
os.Exit(1)