Files
ssh-netbox-wrapper/cmd/netssh/main.go
T
Sebastian UnterschützandClaude Opus 5.5 878701aba7 feat(shortcuts): regex rewrite rules for host shortcuts
shortcuts.rewrites applies regex replacements after the domain suffix
is stripped, e.g. ^([a-z]+)[0-9]+- -> $1 turns fsn1-bx1.hoster.de into
fsnbx1. The shortcut works for connecting (cached hosts), shell
aliases, and Tab completion, where it is offered when the full name
does not match the typed text. Rules are editable in netssh configure,
and netssh alias --test HOSTNAME shows a name's shortcut and the cached
hosts it matches.

The shortcut lookup only ran when domains or strip_hyphens were set;
it now also runs for rewrite-only configurations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 22:42:13 +01:00

877 lines
25 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"text/tabwriter"
"time"
tea "charm.land/bubbletea/v2"
"github.com/spf13/cobra"
"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/notes"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
)
// managedSubcommands are dispatched to cobra; everything else is treated as SSH wrapper mode.
var managedSubcommands = map[string]bool{
"configure": true,
"notes": true,
"search": true,
"cache": true,
"alias": true,
"hook": true,
"shell-init": true,
"completion": true,
"__complete": true,
"help": true,
"version": true,
"uninstall": true,
}
func main() {
args := os.Args[1:]
if len(args) == 0 {
runTUI()
return
}
if args[0] == "--version" {
fmt.Printf("netssh %s\n", buildVersion())
return
}
if managedSubcommands[args[0]] {
if err := rootCmd().Execute(); err != nil {
os.Exit(1)
}
return
}
runSSHWrapper(args)
}
// loadConfigOrSetup loads the config and runs the setup wizard if NetBox is not configured.
func loadConfigOrSetup() *config.Config {
cfg, err := config.Load()
if err != nil {
fatalf("config: %v", err)
}
if !cfg.HasNetBox() {
fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…")
if err := setup.RunWizard(cfg); err != nil {
fatalf("setup: %v", err)
}
cfg, err = config.Load()
if err != nil {
fatalf("config: %v", err)
}
}
return cfg
}
// runSSHWrapper resolves the target hostname via NetBox and execs the native ssh binary.
func runSSHWrapper(args []string) {
flags, args, err := internalssh.ExtractWrapperFlags(args)
if err != nil {
fatalf("%v", err)
}
cfg := loadConfigOrSetup()
parsed := internalssh.Parse(args)
if parsed == nil {
// No destination found — pass arguments straight through to ssh.
execSSH(internalssh.Result{Args: args}, flags)
return
}
connect := func(name, kind, ip string, tags []string) {
connectHost(cfg, flags, name, kind, ip, tags, parsed.DestIdx, args)
}
// Inject the configured default user if none was given on the command line.
if cfg.SSH.DefaultUser != "" && parsed.User == "" && !internalssh.HasUserFlag(args) {
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.CachePath(), cfg.Cache.TTL)
_ = c.Load()
maybeBackgroundRefresh(cfg, c)
source, _ := netbox.SplitTarget(parsed.Host)
useCache := len(cfg.NetBoxes) == 0 || source != ""
// Cache hit with a fresh TTL — connect directly without querying NetBox.
if entry, fresh := c.Get(parsed.Host); fresh && useCache {
c.MarkUsed(parsed.Host)
_ = c.Save()
connect(entry.Name, entry.Kind, entry.IP, entry.Tags)
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 := cfg.Shortcuts.Enabled()
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
normalize := normalizeTarget(cfg.Shortcuts, source != "")
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
if err != nil {
fatalf("%v", err)
}
if found {
if fresh {
c.MarkUsed(entry.Name)
_ = c.Save()
connect(entry.Name, entry.Kind, entry.IP, entry.Tags)
return
}
// Stale shortcut match — use canonical name for NetBox re-fetch.
lookupHost = entry.Name
}
}
if !cfg.HasNetBox() {
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
}
nbClient := newNetBoxClient(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains)
if errors.Is(err, netbox.ErrHostNotFound) && !useCache && shortcutsEnabled {
entry, found, _, cacheErr := c.GetByShortcut(parsed.Host, normalizeTarget(cfg.Shortcuts, false))
if cacheErr != nil {
fatalf("%v", cacheErr)
}
if found {
target, err = findSSHHost(ctx, nbClient, entry.Name, cfg.Shortcuts.Domains)
}
}
if err != nil {
fatalf("NetBox: %v", err)
}
if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind {
c.MarkUsed(target.Target())
_ = c.Save()
connect(cached.Name, cached.Kind, cached.IP, cached.Tags)
return
}
chain, err := resolver.New(cfg.Resolver)
if err != nil {
fatalf("resolver: %v", err)
}
ip, err := chain.Resolve(ctx, &target, nbClient)
if err != nil {
fatalf("IP resolution for %q: %v", parsed.Host, err)
}
c.Upsert(cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags})
c.MarkUsed(target.Target())
_ = c.Save()
connect(target.Target(), target.Kind, ip, target.Tags)
}
// findSSHHost uses the paginated, ambiguity-checking lookup for connection
// targets. Only a genuine missing host permits trying configured suffixes.
func findSSHHost(ctx context.Context, client *netbox.Client, name string, domains []string) (netbox.HostEntry, error) {
target, err := client.FindHost(ctx, name, "")
if !errors.Is(err, netbox.ErrHostNotFound) || strings.Contains(name, ".") {
return target, err
}
for _, domain := range domains {
domain = strings.TrimPrefix(strings.TrimSpace(domain), ".")
if domain == "" {
continue
}
target, err = client.FindHost(ctx, name+"."+domain, "")
if !errors.Is(err, netbox.ErrHostNotFound) {
return target, err
}
}
return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name)
}
// connectHost applies the SSH options configured for the host and execs ssh.
// target is the cache/selector name, which may carry an instance prefix.
func connectHost(cfg *config.Config, flags internalssh.WrapperFlags, target, kind, ip string, tags []string, destIdx int, args []string) {
instance, name := netbox.SplitTarget(target)
opts := cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: kind, Tags: tags})
execSSH(internalssh.Apply(args, destIdx, internalssh.Host{Name: name, IP: ip}, opts, flags), flags)
}
// execSSH replaces the process with ssh, or prints the command for --dry-run.
func execSSH(result internalssh.Result, flags internalssh.WrapperFlags) {
if flags.DryRun {
fmt.Println(internalssh.Quote(append([]string{"ssh"}, result.Args...)))
return
}
if result.Notice != "" {
fmt.Fprintf(os.Stderr, "netssh: %s\n", result.Notice)
}
if err := internalssh.Exec(result.Args); err != nil {
fatalf("%v", err)
}
}
// runTUI starts the interactive host search.
func runTUI() {
cfg := loadConfigOrSetup()
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
_ = c.Load()
maybeBackgroundRefresh(cfg, c)
var nbClient *netbox.Client
if cfg.HasNetBox() {
nbClient = newNetBoxClient(cfg)
}
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
m.SetNotes(notes.New(cfg.NetBox.URL, nbClient))
p := tea.NewProgram(m)
final, err := p.Run()
if err != nil {
fatalf("TUI: %v", err)
}
tuiModel, ok := final.(*tui.Model)
if !ok {
return
}
host := tuiModel.Selected()
if host == nil {
return
}
// Revalidate stale or live TUI results through the configured resolver chain.
var tags []string
if cached, fresh := c.Get(host.Name); fresh && cached.Kind == host.Kind {
host.IP = cached.IP
tags = cached.Tags
} else {
if nbClient == nil {
fatalf("cannot resolve %q: NetBox is not configured", host.Name)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
target, err := nbClient.FindHost(ctx, host.Name, host.Kind)
if err != nil {
fatalf("NetBox: %v", err)
}
chain, err := resolver.New(cfg.Resolver)
if err != nil {
fatalf("resolver: %v", err)
}
host.IP, err = chain.Resolve(ctx, &target, nbClient)
if err != nil {
fatalf("IP resolution: %v", err)
}
c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags})
tags = target.Tags
}
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
var sshArgs []string
// User override from TUI edit mode takes priority, then config default.
user := host.User
if user == "" {
user = cfg.SSH.DefaultUser
}
if user != "" {
sshArgs = append(sshArgs, "-l", user)
}
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)
c.MarkUsed(host.Name)
_ = c.Save()
connectHost(cfg, internalssh.WrapperFlags{}, host.Name, host.Kind, host.IP, tags, len(sshArgs)-1, sshArgs)
}
// --- cobra root + subcommands ---
func rootCmd() *cobra.Command {
root := &cobra.Command{
Use: "netssh",
Short: "SSH wrapper with NetBox hostname resolution",
DisableFlagParsing: true,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
cfg, err := config.Load()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
_ = c.Load()
hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts)
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
hosts = hosts[:limit]
}
completions := make([]cobra.Completion, 0, len(hosts))
for _, host := range hosts {
completions = append(completions, cobra.Completion(host))
}
// Subcommands at the end, after all hostnames.
for _, sub := range cmd.Commands() {
if sub.IsAvailableCommand() && strings.HasPrefix(sub.Name(), toComplete) {
completions = append(completions, cobra.Completion(sub.Name()+"\t"+sub.Short))
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveKeepOrder
},
}
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd())
// cobra builds the "completion" command lazily; force init so we can extend it.
root.InitDefaultCompletionCmd()
for _, cmd := range root.Commands() {
if cmd.Name() == "completion" {
cmd.AddCommand(completionInstallCmd(root))
// Cobra's zsh script spawns a subshell per line and sends every host
// through _describe, which hangs with thousands of cached hosts.
for _, sub := range cmd.Commands() {
if sub.Name() == "zsh" {
sub.RunE = func(cmd *cobra.Command, args []string) error {
_, err := io.WriteString(cmd.OutOrStdout(), hook.ZshCompletionScript)
return err
}
sub.Run = nil
}
}
break
}
}
return root
}
func completionInstallCmd(root *cobra.Command) *cobra.Command {
var shell string
cmd := &cobra.Command{
Use: "install",
Short: "Install shell completion for the current user (no sudo required)",
RunE: func(cmd *cobra.Command, args []string) error {
if shell == "" {
shell = filepath.Base(os.Getenv("SHELL"))
}
var (
dir string
file string
gen func() ([]byte, error)
note string
)
switch shell {
case "bash":
dir = filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions")
file = filepath.Join(dir, "netssh")
gen = func() ([]byte, error) {
var buf strings.Builder
err := root.GenBashCompletionV2(&buf, true)
return []byte(buf.String()), err
}
note = "Reload your shell or run: source " + file
case "zsh":
dir = filepath.Join(os.Getenv("HOME"), ".zfunc")
file = filepath.Join(dir, "_netssh")
gen = func() ([]byte, error) { return []byte(hook.ZshCompletionScript), nil }
note = hook.ZshCompletionNote(dir)
case "fish":
dir = filepath.Join(hook.FishConfigDir(), "completions")
file = filepath.Join(dir, "netssh.fish")
gen = func() ([]byte, error) {
var buf strings.Builder
err := root.GenFishCompletion(&buf, true)
return []byte(buf.String()), err
}
note = "Reload your shell or start a new fish session."
default:
return fmt.Errorf("unsupported shell %q — use --shell bash|zsh|fish", shell)
}
script, err := gen()
if err != nil {
return fmt.Errorf("generating completion: %w", err)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating %s: %w", dir, err)
}
if err := os.WriteFile(file, script, 0o644); err != nil {
return fmt.Errorf("writing %s: %w", file, err)
}
cfg, err := config.Load()
if err != nil {
return err
}
if err := hook.SyncCommandCompletion(cfg.Shell.Alias); err != nil {
return err
}
fmt.Printf("Completion installed → %s\n%s\n", file, note)
if cfg.Shell.Alias != "" {
fmt.Printf("Completion also enabled for alias %q.\n", cfg.Shell.Alias)
}
return nil
},
}
cmd.Flags().StringVar(&shell, "shell", "", "Shell to install for (default: $SHELL). Supported: bash, zsh, fish")
return cmd
}
func configureCmd() *cobra.Command {
return &cobra.Command{
Use: "configure",
Short: "Interactively configure netssh",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
return setup.RunWizard(cfg)
},
}
}
func searchCmd() *cobra.Command {
return &cobra.Command{
Use: "search <query>",
Short: "Print hostnames from the cache (used for shell completion)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
if err := c.Load(); err != nil {
return err
}
q := ""
if len(args) > 0 {
q = args[0]
}
for _, e := range c.Search(q) {
fmt.Println(e.Name)
}
return nil
},
}
}
func cacheCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "cache",
Short: "Manage the local host cache",
}
cmd.AddCommand(cacheListCmd(), cacheClearCmd(), cacheRefreshCmd())
return cmd
}
func cacheListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "Show all cached entries",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
if err := c.Load(); err != nil {
return err
}
entries := c.All()
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tIP\tKIND\tCACHED AT")
for _, e := range entries {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.Name, e.IP, e.Kind, e.CachedAt.Format(time.RFC3339))
}
return w.Flush()
},
}
}
func cacheClearCmd() *cobra.Command {
return &cobra.Command{
Use: "clear",
Short: "Remove all cached entries",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
c.Clear()
if err := c.Save(); err != nil {
return err
}
fmt.Println("Cache cleared.")
return nil
},
}
}
func cacheRefreshCmd() *cobra.Command {
var filterTag, filterKind, filterInstance string
cmd := &cobra.Command{
Use: "refresh",
Short: "Re-fetch all known hosts from NetBox",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
if !cfg.HasNetBox() {
return fmt.Errorf("netbox.url is not configured")
}
nbClient := newNetBoxClient(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind}
query := ""
if filterInstance != "" {
if !nbClient.MultipleSources() {
return fmt.Errorf("--instance requires named netboxes configuration")
}
query = filterInstance + "/"
}
entries, err := nbClient.SearchAll(ctx, query, opts)
if err != nil {
return fmt.Errorf("NetBox: %w", err)
}
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
_ = c.Load()
chain, err := resolver.New(cfg.Resolver)
if err != nil {
return fmt.Errorf("resolver: %w", err)
}
written := 0
nameCounts := make(map[string]int)
for _, e := range entries {
nameCounts[strings.ToLower(e.Target())]++
}
for i := range entries {
e := &entries[i]
if nameCounts[strings.ToLower(e.Target())] > 1 {
c.Delete(e.Target())
fmt.Fprintf(os.Stderr, "warning: skipping ambiguous hostname %q\n", e.Name)
continue
}
ip, err := chain.Resolve(ctx, e, nbClient)
if err != nil {
// Do not turn a failed configured strategy into a fresh
// primary-IP cache hit that bypasses the resolver.
if ctx.Err() != nil {
return ctx.Err()
}
c.Delete(e.Target())
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err)
continue
}
c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags})
written++
}
if err := c.Save(); err != nil {
return err
}
if filterTag == "" && filterKind == "" && filterInstance == "" {
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", written)
return nil
},
}
cmd.Flags().StringVar(&filterTag, "tag", "", "Filter by NetBox tag slug (e.g. prod)")
cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm")
cmd.Flags().StringVar(&filterInstance, "instance", "", "Refresh only this named NetBox instance")
return cmd
}
// buildAliasEntries computes the sorted, deduplicated alias list from the cache.
func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry {
return hook.BuildAliasEntries(c.All(), cfg)
}
func aliasCmd() *cobra.Command {
var shell, test 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, rewrite rules applied, hyphens optionally stripped).
Check a rule with: netssh alias --test fsn1-bx1.example.com
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.CachePath(), cfg.Cache.TTL)
if err := c.Load(); err != nil {
return err
}
if test != "" {
printShortcutTest(os.Stdout, test, c.All(), cfg.Shortcuts)
return nil
}
if shell == "" {
shell = filepath.Base(os.Getenv("SHELL"))
}
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(entries))
for _, e := range entries {
fmt.Println(e.Definition("fish"))
}
default:
fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(entries))
for _, e := range entries {
fmt.Println(e.Definition("bash"))
}
}
return nil
},
}
cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)")
cmd.Flags().StringVar(&test, "test", "", "Show the shortcut for a hostname and the cached hosts it matches")
return cmd
}
// printShortcutTest helps writing shortcut rules: it shows what a name is
// shortened to and which cached hosts share that shortcut.
func printShortcutTest(w io.Writer, name string, entries []cache.Entry, sc config.ShortcutsConfig) {
normalize := normalizeTarget(sc, strings.Contains(name, "/"))
short := normalize(name)
fmt.Fprintf(w, "%s → %s\n", name, short)
var matches []string
for _, e := range entries {
if normalize(e.Name) == short {
matches = append(matches, e.Name)
}
}
sort.Strings(matches)
switch len(matches) {
case 0:
fmt.Fprintln(w, "no cached host has this shortcut")
case 1:
fmt.Fprintf(w, "matches %s\n", matches[0])
default:
fmt.Fprintf(w, "ambiguous, matches %d hosts:\n", len(matches))
for _, m := range matches {
fmt.Fprintf(w, " %s\n", m)
}
}
}
// 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: "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()
if err != nil {
return nil
}
c := cache.New(cfg.CachePath(), 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.
_ = hook.WriteAliasFiles(buildAliasEntries(c, *cfg))
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: `Adds a marked block (# >>> netssh >>> … # <<< netssh <<<) to your shell
profile that runs 'netssh shell-init' on every new shell session and, for zsh,
registers the completion. Unmarked entries from older versions are replaced.
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
}
// The managed block also sources the aliases (bash/zsh) and registers
// zsh completion; fish uses conf.d auto-sourcing instead.
installed, err := hook.Install(profile, shell)
if err != nil {
return err
}
if !installed {
fmt.Printf("Hook already installed in %s\n", profile)
} 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.HasNetBox() {
c := cache.New(cfg.CachePath(), 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.")
}
}
}
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.HasNetBox() {
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)
}