feat(hook): write marked profile blocks and register zsh completion

`netssh hook install` and the setup wizard now write the same marked
`# >>> netssh >>>` block as install.sh and replace unmarked entries of
older versions instead of appending duplicates. Uninstall shares the
legacy patterns with the installer (eval/alias/source variants).

For zsh the block adds ~/.zfunc to fpath and calls compdef, so
completion also works when compinit already ran earlier in .zshrc,
e.g. with oh-my-zsh. The installer reports which completion it set up
per shell and how to reset ~/.zcompdump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sebastian UnterschützandClaude Opus 5.5 committed 2026-10-28 11:22:53 +01:00
1 parent 8fb7633783
commit 129a7201bc
6 files changed
+221 -55

No files matched your search

+77 -10
View File
@@ -2,6 +2,7 @@ package hook_test
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -87,7 +88,7 @@ func TestIsInstalled_MissingFile(t *testing.T) {
func TestInstall_Fresh(t *testing.T) {
profile := filepath.Join(t.TempDir(), ".bashrc")
installed, err := hook.Install(profile)
installed, err := hook.Install(profile, "bash")
if err != nil {
t.Fatalf("Install: %v", err)
}
@@ -102,8 +103,8 @@ func TestInstall_Fresh(t *testing.T) {
func TestInstall_Idempotent(t *testing.T) {
profile := filepath.Join(t.TempDir(), ".bashrc")
hook.Install(profile)
installed, err := hook.Install(profile)
hook.Install(profile, "bash")
installed, err := hook.Install(profile, "bash")
if err != nil {
t.Fatalf("second Install: %v", err)
}
@@ -120,7 +121,7 @@ func TestInstall_Idempotent(t *testing.T) {
func TestInstall_CreatesProfileAndParentDirs(t *testing.T) {
profile := filepath.Join(t.TempDir(), "nested", "dir", ".zshrc")
if _, err := hook.Install(profile); err != nil {
if _, err := hook.Install(profile, "bash"); err != nil {
t.Fatalf("Install should create missing directories: %v", err)
}
if _, err := os.Stat(profile); err != nil {
@@ -131,7 +132,7 @@ func TestInstall_CreatesProfileAndParentDirs(t *testing.T) {
func TestInstall_PreservesExistingContent(t *testing.T) {
profile := writeProfile(t, "export PATH=$PATH:/usr/local/bin\n")
hook.Install(profile)
hook.Install(profile, "bash")
data, _ := os.ReadFile(profile)
content := string(data)
@@ -145,13 +146,13 @@ func TestInstall_PreservesExistingContent(t *testing.T) {
func TestInstall_AppendedAtEnd(t *testing.T) {
profile := writeProfile(t, "existing line\n")
hook.Install(profile)
hook.Install(profile, "bash")
data, _ := os.ReadFile(profile)
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
last := lines[len(lines)-1]
if last != hook.Line {
t.Errorf("hook line should be the last line, got %q", last)
if last != hook.BlockEnd {
t.Errorf("managed block should end the profile, got %q", last)
}
}
@@ -226,7 +227,7 @@ func TestUninstall_CollapsesExtraBlankLines(t *testing.T) {
func TestInstallUninstall_Roundtrip(t *testing.T) {
profile := writeProfile(t, "existing content\n")
hook.Install(profile)
hook.Install(profile, "bash")
if !hook.IsInstalled(profile) {
t.Fatal("should be installed after Install")
}
@@ -265,7 +266,7 @@ func writeProfile(t *testing.T, content string) string {
func TestManagedBlockInstallAndUninstall(t *testing.T) {
original := "export KEEP=yes\n# netssh shell-init documentation\necho 'netssh shell-init'\n"
profile := writeProfile(t, original+hook.BlockStart+"\nexport PATH='/custom/bin':\"$PATH\"\n'/custom/bin/netssh' shell-init\n[ ! -f '/custom/cache/netssh/aliases.sh' ] || . '/custom/cache/netssh/aliases.sh'\n"+hook.BlockEnd+"\n")
if installed, err := hook.Install(profile); err != nil || installed {
if installed, err := hook.Install(profile, "bash"); err != nil || installed {
t.Fatalf("duplicate install: %v, %v", installed, err)
}
if installed, err := hook.InstallAliasesSource(profile); err != nil || installed {
@@ -288,3 +289,69 @@ func TestProfilePathRespectsZdotdir(t *testing.T) {
t.Fatalf("zsh path: %s, %v", path, err)
}
}
func TestInstallReplacesLegacyEntriesWithBlock(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
profile := writeProfile(t, "export KEEP=yes\nnetssh shell-init\nsource ~/.cache/netssh/aliases.sh 2>/dev/null\neval \"$(netssh alias)\"\nalias j='netssh'\necho 'netssh shell-init'\n")
installed, err := hook.Install(profile, "zsh")
if err != nil || !installed {
t.Fatalf("install: %v, %v", installed, err)
}
data, _ := os.ReadFile(profile)
content := string(data)
for _, gone := range []string{"\nnetssh shell-init\nsource", "eval ", "alias j="} {
if strings.Contains(content, gone) {
t.Fatalf("legacy entry %q kept:\n%s", gone, content)
}
}
for _, kept := range []string{"export KEEP=yes", "echo 'netssh shell-init'", hook.BlockStart, "compdef _netssh netssh", "fpath=("} {
if !strings.Contains(content, kept) {
t.Fatalf("missing %q:\n%s", kept, content)
}
}
if strings.Count(content, hook.BlockStart) != 1 {
t.Fatalf("duplicate blocks:\n%s", content)
}
if removed, err := hook.Uninstall(profile); err != nil || !removed {
t.Fatalf("uninstall: %v, %v", removed, err)
}
data, _ = os.ReadFile(profile)
if string(data) != "export KEEP=yes\necho 'netssh shell-init'\n" {
t.Fatalf("unexpected remainder: %q", data)
}
}
func TestBlockPerShell(t *testing.T) {
if b := hook.Block("fish"); strings.Contains(b, "aliases.sh") || strings.Contains(b, "compdef") {
t.Fatalf("fish block: %s", b)
}
if b := hook.Block("bash"); !strings.Contains(b, "aliases.sh") || strings.Contains(b, "compdef") {
t.Fatalf("bash block: %s", b)
}
}
func TestZshBlockRegistersCompletionAfterCompinit(t *testing.T) {
zsh, err := exec.LookPath("zsh")
if err != nil {
t.Skip("zsh unavailable")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("ZDOTDIR", home)
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache"))
if err := os.MkdirAll(hook.ZshFuncDir(), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(hook.ZshFuncDir(), "_netssh"), []byte(hook.ZshCompletionScript), 0o644); err != nil {
t.Fatal(err)
}
// compinit runs before the block, as with oh-my-zsh.
rc := "autoload -Uz compinit && compinit -u -d \"$HOME/.zcompdump\"\nnetssh() { :; }\n" + hook.Block("zsh")
if err := os.WriteFile(filepath.Join(home, ".zshrc"), []byte(rc), 0o644); err != nil {
t.Fatal(err)
}
out, err := exec.Command(zsh, "-i", "-c", `print -r -- $_comps[netssh]`).CombinedOutput()
if err != nil || !strings.Contains(string(out), "_netssh") {
t.Fatalf("completion not registered: %v\n%s", err, out)
}
}