This commit is contained in:
1 parent
14d519d010
commit
34503de68e
7 files changed
+319
-1
No files matched your search
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.0.16
|
||||||
|
|
||||||
|
- Enable Bash, Zsh, and Fish completion for the configured command alias, such as `j`.
|
||||||
|
- Update or remove generated alias completion files when the command alias changes.
|
||||||
|
|
||||||
## v0.0.15
|
## v0.0.15
|
||||||
|
|
||||||
- Use `https://git.zb-server.de` for release download links and API requests instead of the runner's internal Gitea service URL.
|
- Use `https://git.zb-server.de` for release download links and API requests instead of the runner's internal Gitea service URL.
|
||||||
|
|||||||
@@ -169,6 +169,14 @@ it for new shells. The command alias takes priority over a host alias with the
|
|||||||
same name and works even when no servers are cached. Environment override:
|
same name and works even when no servers are cached. Environment override:
|
||||||
`NETSSH_SHELL_ALIAS=j`.
|
`NETSSH_SHELL_ALIAS=j`.
|
||||||
|
|
||||||
|
The command alias also supports shell completion: `j ser<Tab>` suggests cached
|
||||||
|
server names, just like `netssh ser<Tab>`. The wizard, shell hook, installer, and
|
||||||
|
`netssh completion install` maintain the alias completion files automatically.
|
||||||
|
Changing or disabling the alias removes its old generated completion files.
|
||||||
|
Bash requires bash-completion to load user completion files; for Zsh, include
|
||||||
|
`~/.zfunc` in `fpath` before running `compinit` as described below. Fish loads
|
||||||
|
completion files automatically. Open a new terminal after changing the alias.
|
||||||
|
|
||||||
### API tokens
|
### API tokens
|
||||||
|
|
||||||
NetBox supports two token formats:
|
NetBox supports two token formats:
|
||||||
|
|||||||
@@ -388,7 +388,17 @@ func completionInstallCmd(root *cobra.Command) *cobra.Command {
|
|||||||
if err := os.WriteFile(file, script, 0o644); err != nil {
|
if err := os.WriteFile(file, script, 0o644); err != nil {
|
||||||
return fmt.Errorf("writing %s: %w", file, err)
|
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)
|
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
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ func TestLocalNotesCLI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestShellInitUpdatesAndRemovesCommandAliasWithoutHosts(t *testing.T) {
|
func TestShellInitUpdatesAndRemovesCommandAliasWithoutHosts(t *testing.T) {
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||||
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
|
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
|
||||||
|
|||||||
@@ -49,7 +49,14 @@ func WriteAliasFiles(entries []AliasEntry) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
writeFishAliasFile(entries) // best-effort — never blocks the caller on failure
|
writeFishAliasFile(entries) // best-effort — never blocks the caller on failure
|
||||||
return nil
|
alias := ""
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Host == "" {
|
||||||
|
alias = entry.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SyncCommandCompletion(alias)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeShAliasFile(entries []AliasEntry) error {
|
func writeShAliasFile(entries []AliasEntry) error {
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package hook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
const completionMarker = "# netssh command alias completion — generated automatically"
|
||||||
|
|
||||||
|
// SyncCommandCompletion installs lazy loaders for the configured command alias.
|
||||||
|
// The loaders obtain the canonical completion from the current netssh binary,
|
||||||
|
// so aliases configured by the wizard work without a separate completion install.
|
||||||
|
func SyncCommandCompletion(alias string) error {
|
||||||
|
if err := config.ValidateCommandAlias(alias); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
statePath := filepath.Join(filepath.Dir(AliasesPath()), "completion_alias")
|
||||||
|
data, err := os.ReadFile(statePath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
previous := strings.TrimSpace(string(data))
|
||||||
|
if err := config.ValidateCommandAlias(previous); err != nil {
|
||||||
|
return fmt.Errorf("invalid completion alias state: %w", err)
|
||||||
|
}
|
||||||
|
if alias == "" && previous == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||||
|
if previous != "" && previous != alias {
|
||||||
|
path := commandCompletionPath(shell, previous)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Only delete files owned by netssh; preserve any user replacements.
|
||||||
|
if strings.HasPrefix(string(data), completionMarker+"\n") || strings.HasPrefix(string(data), "#compdef "+previous+"\n"+completionMarker+"\n") {
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alias == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if shell == "fish" {
|
||||||
|
if _, err := os.Stat(FishConfigDir()); os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path := commandCompletionPath(shell, alias)
|
||||||
|
script := commandCompletionScript(shell, alias)
|
||||||
|
existing, err := os.ReadFile(path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if string(existing) == script {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := storage.WriteFile(path, []byte(script), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("writing %s alias completion: %w", shell, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alias == "" {
|
||||||
|
if err := os.Remove(statePath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return storage.WriteFile(statePath, []byte(alias+"\n"), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandCompletionPath(shell, alias string) string {
|
||||||
|
switch shell {
|
||||||
|
case "bash":
|
||||||
|
return filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", alias)
|
||||||
|
case "zsh":
|
||||||
|
return filepath.Join(os.Getenv("HOME"), ".zfunc", "_"+alias)
|
||||||
|
default:
|
||||||
|
return filepath.Join(FishConfigDir(), "completions", alias+".fish")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandCompletionScript(shell, alias string) string {
|
||||||
|
switch shell {
|
||||||
|
case "bash":
|
||||||
|
return fmt.Sprintf(`%s
|
||||||
|
source <(netssh completion bash)
|
||||||
|
if [[ $(type -t compopt) == builtin ]]; then
|
||||||
|
complete -o default -F __start_netssh %s
|
||||||
|
else
|
||||||
|
complete -o default -o nospace -F __start_netssh %s
|
||||||
|
fi
|
||||||
|
`, completionMarker, alias, alias)
|
||||||
|
case "zsh":
|
||||||
|
return fmt.Sprintf(`#compdef %s
|
||||||
|
%s
|
||||||
|
source <(netssh completion zsh)
|
||||||
|
compdef _netssh %s
|
||||||
|
_netssh "$@"
|
||||||
|
`, alias, completionMarker, alias)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf(`%s
|
||||||
|
netssh completion fish | source
|
||||||
|
complete --command %s --wraps netssh
|
||||||
|
`, completionMarker, alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package hook_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func completionEnvironment(t *testing.T) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config"))
|
||||||
|
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache"))
|
||||||
|
if err := os.MkdirAll(hook.FishConfigDir(), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return map[string]string{
|
||||||
|
"bash": filepath.Join(home, ".local", "share", "bash-completion", "completions", "j"),
|
||||||
|
"zsh": filepath.Join(home, ".zfunc", "_j"),
|
||||||
|
"fish": filepath.Join(hook.FishConfigDir(), "completions", "j.fish"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandCompletionTracksAliasChanges(t *testing.T) {
|
||||||
|
paths := completionEnvironment(t)
|
||||||
|
if err := hook.WriteAliasFiles([]hook.AliasEntry{{Name: "j"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A manually replaced completion file must survive removal of the old alias.
|
||||||
|
custom := "# user-owned completion\n"
|
||||||
|
if err := os.WriteFile(paths["bash"], []byte(custom), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := hook.WriteAliasFiles([]hook.AliasEntry{{Name: "k"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for shell, path := range paths {
|
||||||
|
if shell == "bash" {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil || string(data) != custom {
|
||||||
|
t.Fatal("user completion was removed")
|
||||||
|
}
|
||||||
|
} else if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("stale %s completion remains: %v", shell, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := hook.WriteAliasFiles(nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{
|
||||||
|
filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", "k"),
|
||||||
|
filepath.Join(os.Getenv("HOME"), ".zfunc", "_k"),
|
||||||
|
filepath.Join(hook.FishConfigDir(), "completions", "k.fish"),
|
||||||
|
} {
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("disabled completion remains: %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandAliasCompletesServerNamesAcrossShells(t *testing.T) {
|
||||||
|
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||||
|
t.Run(shell, func(t *testing.T) {
|
||||||
|
executable, err := exec.LookPath(shell)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("%s unavailable", shell)
|
||||||
|
}
|
||||||
|
paths := completionEnvironment(t)
|
||||||
|
if err := hook.SyncCommandCompletion("j"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fixtures := t.TempDir()
|
||||||
|
root := &cobra.Command{Use: "netssh"}
|
||||||
|
var canonical bytes.Buffer
|
||||||
|
switch shell {
|
||||||
|
case "bash":
|
||||||
|
err = root.GenBashCompletionV2(&canonical, true)
|
||||||
|
case "zsh":
|
||||||
|
err = root.GenZshCompletion(&canonical)
|
||||||
|
case "fish":
|
||||||
|
err = root.GenFishCompletion(&canonical, true)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(fixtures, shell), canonical.Bytes(), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Exercise Cobra's actual generated shell code with a deterministic backend.
|
||||||
|
backend := `#!/bin/bash
|
||||||
|
case "$1" in
|
||||||
|
completion) cat "$COMPLETION_FIXTURES/$2" ;;
|
||||||
|
__complete|__completeNoDesc) printf 'server01\n:4\n' ;;
|
||||||
|
*) exit 1 ;;
|
||||||
|
esac
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(fixtures, "netssh"), []byte(backend), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("COMPLETION_FIXTURES", fixtures)
|
||||||
|
t.Setenv("ALIAS_COMPLETION", paths[shell])
|
||||||
|
t.Setenv("PATH", fixtures+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
var script string
|
||||||
|
var args []string
|
||||||
|
switch shell {
|
||||||
|
case "bash":
|
||||||
|
library := ""
|
||||||
|
for _, path := range []string{"/usr/share/bash-completion/bash_completion", "/etc/bash_completion"} {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
library = path
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if library == "" {
|
||||||
|
t.Skip("bash-completion unavailable")
|
||||||
|
}
|
||||||
|
t.Setenv("BASH_COMPLETION_LIBRARY", library)
|
||||||
|
args = []string{"--noprofile", "--norc", "-c"}
|
||||||
|
script = `source "$BASH_COMPLETION_LIBRARY"
|
||||||
|
shopt -s expand_aliases
|
||||||
|
alias j=netssh
|
||||||
|
_completion_loader j
|
||||||
|
COMP_WORDS=(j ser)
|
||||||
|
COMP_CWORD=1
|
||||||
|
COMP_LINE='j ser'
|
||||||
|
COMP_POINT=5
|
||||||
|
COMP_TYPE=9
|
||||||
|
COMP_KEY=9
|
||||||
|
__start_netssh j ser j
|
||||||
|
printf '%s\n' "${COMPREPLY[@]}"
|
||||||
|
`
|
||||||
|
case "zsh":
|
||||||
|
args = []string{"-f", "-c"}
|
||||||
|
script = `fpath=("$HOME/.zfunc" $fpath)
|
||||||
|
autoload -Uz compinit
|
||||||
|
compinit -u -D
|
||||||
|
alias j=netssh
|
||||||
|
autoload -Uz _j
|
||||||
|
# Capture matches without requiring an interactive ZLE completion widget.
|
||||||
|
_describe() { print -rl -- "${completions[@]}"; }
|
||||||
|
words=(j ser)
|
||||||
|
CURRENT=2
|
||||||
|
_j
|
||||||
|
`
|
||||||
|
case "fish":
|
||||||
|
args = []string{"--no-config", "-c"}
|
||||||
|
script = `alias j netssh
|
||||||
|
set -p fish_complete_path "$XDG_CONFIG_HOME/fish/completions"
|
||||||
|
complete --do-complete 'j ser'
|
||||||
|
`
|
||||||
|
}
|
||||||
|
cmd := exec.Command(executable, append(args, script)...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil || !strings.Contains(string(out), "server01") {
|
||||||
|
t.Fatalf("%s completion: %v\n%s", shell, err, out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user