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>
This commit is contained in:
Sebastian UnterschützandClaude Opus 5.5 committed 2026-11-13 22:42:13 +01:00
1 parent d51c040d9f
commit 878701aba7
12 files changed
+408 -21

No files matched your search

+56 -6
View File
@@ -41,7 +41,9 @@ func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) st
// all instances is offered bare ("web01"), so it completes like with a single
// NetBox; connecting to it still checks every instance for ambiguity.
// Names present in several instances stay qualified ("prod/web01").
func hostCompletions(entries []cache.Entry, toComplete string) []string {
// Hosts whose full name does not match are offered by their configured
// shortcut instead ("fsnb" → "fsnbx1" for fsn1-bx1.example.com).
func hostCompletions(entries []cache.Entry, toComplete string, sc config.ShortcutsConfig) []string {
prefix := strings.ToLower(toComplete)
qualifiedInput := strings.Contains(prefix, "/")
instances := make(map[string]int)
@@ -49,21 +51,69 @@ func hostCompletions(entries []cache.Entry, toComplete string) []string {
_, name := netbox.SplitTarget(e.Name)
instances[strings.ToLower(name)]++
}
var out []string
var unmatched []cache.Entry
var names []string
for _, e := range entries {
source, name := netbox.SplitTarget(e.Name)
target := strings.ToLower(e.Name)
switch {
case source != "" && !qualifiedInput && strings.HasPrefix(strings.ToLower(name), prefix):
if instances[strings.ToLower(name)] == 1 {
out = append(out, name)
names = append(names, name)
} else {
out = append(out, e.Name)
names = append(names, e.Name)
}
case strings.HasPrefix(target, prefix):
out = append(out, e.Name)
names = append(names, e.Name)
default:
unmatched = append(unmatched, e)
}
}
if sc.Enabled() {
names = append(names, shortcutCompletions(entries, unmatched, prefix, sc)...)
}
sort.Strings(names)
return names
}
// shortcutCompletions offers the shortcut of each unmatched host when it starts
// with prefix and selects exactly one host, qualified by instance if needed.
func shortcutCompletions(all, unmatched []cache.Entry, prefix string, sc config.ShortcutsConfig) []string {
bare := make(map[string]int)
qualified := make(map[string]int)
short := func(e cache.Entry) (string, string) {
source, name := netbox.SplitTarget(e.Name)
s := shortcuts.Normalize(name, sc)
if source == "" {
return s, s
}
return s, strings.ToLower(source) + "/" + s
}
for _, e := range all {
b, q := short(e)
bare[b]++
qualified[q]++
}
seen := make(map[string]bool)
var out []string
for _, e := range unmatched {
b, q := short(e)
candidate := ""
switch {
case strings.Contains(prefix, "/"):
if qualified[q] == 1 && strings.HasPrefix(q, prefix) {
candidate = q
}
case !strings.HasPrefix(b, prefix):
case bare[b] == 1:
candidate = b
case q != b && qualified[q] == 1:
candidate = q // like full names: zsh accepts it, bash/fish ignore it
}
if candidate != "" && !seen[candidate] {
seen[candidate] = true
out = append(out, candidate)
}
}
sort.Strings(out)
return out
}
+82 -2
View File
@@ -228,12 +228,92 @@ func TestHostCompletionsMatchTypedText(t *testing.T) {
// An instance name typed without the slash completes qualified targets.
{"st", []string{"stg/fsn1-bx2.example.com", "stg/hel1-tb1.example.com"}},
} {
if got := hostCompletions(entries, tc.input); !reflect.DeepEqual(got, tc.want) {
if got := hostCompletions(entries, tc.input, config.ShortcutsConfig{}); !reflect.DeepEqual(got, tc.want) {
t.Errorf("%q: got %v, want %v", tc.input, got, tc.want)
}
}
// Single-instance caches are unchanged.
if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w"); !reflect.DeepEqual(got, []string{"web01"}) {
if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w", config.ShortcutsConfig{}); !reflect.DeepEqual(got, []string{"web01"}) {
t.Errorf("single instance: %v", got)
}
}
var fsnRewrite = config.ShortcutsConfig{
Domains: []string{".hoster.de"},
Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}},
}
func TestHostCompletionsOfferShortcuts(t *testing.T) {
entries := []cache.Entry{
{Name: "prod/fsn1-bx1.hoster.de"},
{Name: "prod/fsn1-db1.hoster.de"},
{Name: "stg/fsn1-db1.hoster.de"},
{Name: "prod/web01.hoster.de"},
}
for _, tc := range []struct {
input string
want []string
}{
{"fsnb", []string{"fsnbx1"}},
// Shortcut shared by two instances stays qualified.
{"fsnd", []string{"prod/fsndb1", "stg/fsndb1"}},
{"prod/fsn", []string{"prod/fsn1-bx1.hoster.de", "prod/fsn1-db1.hoster.de"}},
{"stg/fsnd", []string{"stg/fsndb1"}},
// A matching full name is not repeated as shortcut.
{"web", []string{"web01.hoster.de"}},
} {
if got := hostCompletions(entries, tc.input, fsnRewrite); !reflect.DeepEqual(got, tc.want) {
t.Errorf("%q: got %v, want %v", tc.input, got, tc.want)
}
}
// Two hosts of the same instance with one shortcut cannot be selected by it.
same := []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}}
if got := hostCompletions(same, "fsnb", fsnRewrite); len(got) != 0 {
t.Errorf("ambiguous shortcut offered: %v", got)
}
}
func TestShortcutTestOutput(t *testing.T) {
var out bytes.Buffer
printShortcutTest(&out, "fsn1-bx1.hoster.de", []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}, {Name: "web01.hoster.de"}}, fsnRewrite)
want := "fsn1-bx1.hoster.de → fsnbx1\nambiguous, matches 2 hosts:\n fsn1-bx1.hoster.de\n fsn2-bx1.hoster.de\n"
if out.String() != want {
t.Fatalf("got:\n%s", out.String())
}
}
// Rewrite rules alone must enable the shortcut lookup; NetBox is unreachable,
// so only the cached host can answer.
func TestWrapperConnectsByRewriteShortcut(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("NETSSH_NETBOX_URL", "")
t.Setenv("NETSSH_NETBOX_TOKEN", "")
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
t.Setenv("NETSSH_SHELL_ALIAS", "")
data := "netbox:\n url: http://127.0.0.1:1\n token: t\nshortcuts:\n rewrites:\n - pattern: '^([a-z]+)[0-9]+-'\n replace: '$1'\n"
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
c := cache.New(cfg.CachePath(), 3600)
c.Upsert(cache.Entry{Name: "fsn1-bx1.hoster.de", IP: "10.0.0.9", Kind: "device"})
if err := c.Save(); err != nil {
t.Fatal(err)
}
bin := t.TempDir()
if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestMultiInstanceWrapperHelper$")
cmd.Env = append(os.Environ(), "NETSSH_TEST_MULTI_TARGET=fsnbx1.hoster.de", "PATH="+bin+":"+os.Getenv("PATH"))
out, err := cmd.CombinedOutput()
if err != nil || !strings.Contains(string(out), "10.0.0.9\n") {
t.Fatalf("shortcut not resolved from cache: %s, %v", out, err)
}
}
+37 -4
View File
@@ -132,7 +132,7 @@ func runSSHWrapper(args []string) {
// 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
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)
@@ -339,7 +339,7 @@ func rootCmd() *cobra.Command {
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
_ = c.Load()
hosts := hostCompletions(c.All(), toComplete)
hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts)
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
hosts = hosts[:limit]
}
@@ -645,13 +645,15 @@ func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry {
}
func aliasCmd() *cobra.Command {
var shell string
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, hyphens optionally stripped).
(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)"
@@ -668,6 +670,10 @@ Or use in a script:
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"))
}
@@ -694,9 +700,36 @@ Or use in a script:
},
}
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 {