Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa646f25a6 |
@@ -329,7 +329,12 @@ Completions are served from the local cache — no network request on every `<Ta
|
||||
|
||||
## Shell Hook
|
||||
|
||||
The shell hook runs `netssh shell-init` at the start of every new shell session. It checks whether the cache is older than 24 hours and, if so, starts a background refresh. The check reads a single small file and adds no measurable delay to your shell startup.
|
||||
`netssh hook install` sets up two things in your shell profile:
|
||||
|
||||
1. **`netssh shell-init`** — runs on every new shell session. It regenerates the alias file from the current cache so your aliases are always up to date, and starts a background cache refresh if the last full refresh was more than 24 hours ago.
|
||||
2. **A `source` line** (bash/zsh only) — loads the generated alias file so shortcuts like `web01` work immediately in every new shell. Fish uses `conf.d/` auto-sourcing instead, so no explicit source line is needed.
|
||||
|
||||
The alias file is also generated immediately during `hook install`, so aliases are available as soon as you reload your profile — no `cache refresh` needed first (though the cache must not be empty).
|
||||
|
||||
### Install
|
||||
|
||||
@@ -340,21 +345,38 @@ netssh hook install --shell zsh
|
||||
netssh hook install --shell fish
|
||||
```
|
||||
|
||||
This appends exactly one line to your shell profile:
|
||||
Example output:
|
||||
|
||||
```sh
|
||||
```
|
||||
Hook installed → /home/user/.bashrc
|
||||
Reload with: source /home/user/.bashrc
|
||||
42 aliases written to /home/user/.cache/netssh/aliases.sh
|
||||
```
|
||||
|
||||
What gets written to the profile:
|
||||
|
||||
**bash / zsh** (`~/.bashrc` or `~/.zshrc`):
|
||||
```bash
|
||||
netssh shell-init # netssh cache auto-refresh
|
||||
source ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases
|
||||
```
|
||||
|
||||
**fish** (`~/.config/fish/config.fish`):
|
||||
```fish
|
||||
netssh shell-init # netssh cache auto-refresh
|
||||
```
|
||||
|
||||
| Shell | Profile file |
|
||||
|-------|-------------|
|
||||
| bash | `~/.bashrc` |
|
||||
| zsh | `~/.zshrc` |
|
||||
| fish | `~/.config/fish/config.fish` |
|
||||
Fish alias file (`~/.config/fish/conf.d/netssh.fish`) is written automatically and sourced by fish on every shell start without any profile changes.
|
||||
|
||||
The install is idempotent — running it again does nothing if the hook is already present.
|
||||
| Shell | Profile file | Alias file |
|
||||
|-------|-------------|------------|
|
||||
| bash | `~/.bashrc` | `~/.cache/netssh/aliases.sh` |
|
||||
| zsh | `~/.zshrc` | `~/.cache/netssh/aliases.sh` |
|
||||
| fish | `~/.config/fish/config.fish` | `~/.config/fish/conf.d/netssh.fish` |
|
||||
|
||||
Reload your profile after installation:
|
||||
The install is idempotent — running it again changes nothing if the lines are already present.
|
||||
|
||||
Reload your profile after installation to activate immediately:
|
||||
|
||||
```sh
|
||||
source ~/.bashrc # bash
|
||||
@@ -362,6 +384,18 @@ source ~/.zshrc # zsh
|
||||
source ~/.config/fish/config.fish # fish
|
||||
```
|
||||
|
||||
### Alias file updates
|
||||
|
||||
The alias file is regenerated automatically — no manual action needed:
|
||||
|
||||
| Event | Aliases updated |
|
||||
|-------|----------------|
|
||||
| New shell session (`shell-init`) | Yes — from the current local cache |
|
||||
| `netssh cache refresh` | Yes — after the full refresh completes |
|
||||
| Background 24h auto-refresh | Yes — after the background refresh completes |
|
||||
|
||||
Aliases for newly added hosts appear in the next shell session after a `cache refresh` has run.
|
||||
|
||||
### Uninstall
|
||||
|
||||
```sh
|
||||
@@ -369,16 +403,18 @@ netssh hook uninstall # auto-detects $SHELL
|
||||
netssh hook uninstall --shell zsh
|
||||
```
|
||||
|
||||
Removes the `netssh shell-init` line from the profile and collapses any blank lines left behind.
|
||||
Removes both the `netssh shell-init` line and the `source aliases.sh` line from the profile. The alias files (`aliases.sh`, `netssh.fish`) are left on disk — delete them manually if desired.
|
||||
|
||||
### How it differs from the connect-time trigger
|
||||
### Relation to the connect-time auto-refresh
|
||||
|
||||
The shell hook and the connect-time trigger (which fires on each `netssh` invocation) are independent but complementary:
|
||||
|
||||
| Trigger | When it fires |
|
||||
|---------|--------------|
|
||||
| Connect / TUI start | On the next SSH command or `netssh` TUI after 24 h |
|
||||
| Shell hook | On the first new shell session after 24 h |
|
||||
| SSH connect / TUI start | On the next `netssh` call after 24 h have elapsed |
|
||||
| Shell hook (`shell-init`) | On the first new shell session after 24 h have elapsed |
|
||||
|
||||
Both triggers are non-blocking: the refresh runs in the background and your SSH connection (or prompt) is not delayed. You can install both — they share the same `~/.cache/netssh/last_refresh` timestamp, so the background process runs at most once per 24 hours regardless of how many shells or connections you open.
|
||||
Both are non-blocking — the refresh runs as a background process and never delays your prompt or SSH connection. They share `~/.cache/netssh/last_refresh`, so only one background refresh runs per 24-hour window regardless of how many shells or connections are opened.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
+75
-38
@@ -521,6 +521,11 @@ func cacheRefreshCmd() *cobra.Command {
|
||||
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", len(entries))
|
||||
return nil
|
||||
},
|
||||
@@ -530,6 +535,27 @@ func cacheRefreshCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// buildAliasEntries computes the sorted, deduplicated alias list from the cache.
|
||||
func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry {
|
||||
all := c.All()
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name })
|
||||
|
||||
seen := make(map[string]string)
|
||||
var out []hook.AliasEntry
|
||||
for _, e := range all {
|
||||
name := shortcuts.AliasName(e.Name, cfg.Shortcuts)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[name] = e.Name
|
||||
out = append(out, hook.AliasEntry{Name: name, Host: e.Name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func aliasCmd() *cobra.Command {
|
||||
var shell string
|
||||
cmd := &cobra.Command{
|
||||
@@ -558,46 +584,23 @@ Or use in a script:
|
||||
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 {
|
||||
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(lines))
|
||||
default:
|
||||
fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(lines))
|
||||
fmt.Printf("# netssh aliases (%d hosts) — source with: netssh alias --shell fish | source\n", len(entries))
|
||||
for _, e := range entries {
|
||||
fmt.Printf("alias %s 'netssh %s'\n", e.Name, e.Host)
|
||||
}
|
||||
default:
|
||||
fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(entries))
|
||||
for _, e := range entries {
|
||||
fmt.Printf("alias %s='netssh %s'\n", e.Name, e.Host)
|
||||
}
|
||||
for _, l := range lines {
|
||||
fmt.Println(l)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -606,12 +609,12 @@ Or use in a script:
|
||||
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.
|
||||
// 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: "Trigger a background cache refresh if stale (add to shell profile via 'hook install')",
|
||||
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()
|
||||
@@ -619,6 +622,14 @@ func shellInitCmd() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
c := cache.New(cfg.Cache.Path, 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.
|
||||
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
|
||||
_ = hook.WriteAliasFiles(entries)
|
||||
}
|
||||
|
||||
maybeBackgroundRefresh(cfg, c)
|
||||
return nil
|
||||
},
|
||||
@@ -652,15 +663,41 @@ After installation, reload your profile or open a new shell.`,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
installed, err := hook.Install(profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// For bash/zsh: also add the source line so aliases are loaded on startup.
|
||||
// Fish uses conf.d auto-sourcing — no explicit source line needed.
|
||||
if shell != "fish" {
|
||||
if _, err := hook.InstallAliasesSource(profile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !installed {
|
||||
fmt.Printf("Hook already installed in %s\n", profile)
|
||||
return nil
|
||||
}
|
||||
} 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.NetBox.URL != "" {
|
||||
c := cache.New(cfg.Cache.Path, 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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AliasEntry holds a single shell alias mapping.
|
||||
type AliasEntry struct {
|
||||
Name string // short alias name (e.g. "web01")
|
||||
Host string // canonical NetBox hostname (e.g. "web01.example.com")
|
||||
}
|
||||
|
||||
// AliasesPath returns the path for the bash/zsh aliases file.
|
||||
func AliasesPath() string {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
cacheDir = filepath.Join(os.Getenv("HOME"), ".cache")
|
||||
}
|
||||
return filepath.Join(cacheDir, "netssh", "aliases.sh")
|
||||
}
|
||||
|
||||
// FishAliasesPath returns the path for the fish conf.d aliases file.
|
||||
// Fish auto-sources every file in conf.d, so no explicit source line is needed.
|
||||
func FishAliasesPath() string {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
configDir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
}
|
||||
return filepath.Join(configDir, "fish", "conf.d", "netssh.fish")
|
||||
}
|
||||
|
||||
// WriteAliasFiles writes alias definitions to disk for all supported shells.
|
||||
//
|
||||
// The bash/zsh file (~/.cache/netssh/aliases.sh) is always written.
|
||||
// The fish file (~/.config/fish/conf.d/netssh.fish) is written only when
|
||||
// ~/.config/fish/ already exists (i.e. fish is configured on this system).
|
||||
func WriteAliasFiles(entries []AliasEntry) error {
|
||||
if err := writeShAliasFile(entries); err != nil {
|
||||
return err
|
||||
}
|
||||
writeFishAliasFile(entries) // best-effort — never blocks the caller on failure
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeShAliasFile(entries []AliasEntry) error {
|
||||
p := AliasesPath()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return fmt.Errorf("creating aliases dir: %w", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
|
||||
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(&b, "alias %s='netssh %s'\n", e.Name, e.Host)
|
||||
}
|
||||
return os.WriteFile(p, []byte(b.String()), 0o644)
|
||||
}
|
||||
|
||||
func writeFishAliasFile(entries []AliasEntry) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Only write if fish is configured on this system.
|
||||
if _, err := os.Stat(filepath.Join(configDir, "fish")); err != nil {
|
||||
return
|
||||
}
|
||||
p := FishAliasesPath()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
|
||||
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(&b, "alias %s 'netssh %s'\n", e.Name, e.Host)
|
||||
}
|
||||
os.WriteFile(p, []byte(b.String()), 0o644) //nolint:errcheck
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package hook_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||
)
|
||||
|
||||
func TestAliasesPath_UnderCacheDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CACHE_HOME")
|
||||
os.Setenv("XDG_CACHE_HOME", dir)
|
||||
defer os.Setenv("XDG_CACHE_HOME", orig)
|
||||
|
||||
p := hook.AliasesPath()
|
||||
if !strings.HasPrefix(p, dir) {
|
||||
t.Errorf("AliasesPath should be under XDG_CACHE_HOME, got %q", p)
|
||||
}
|
||||
if !strings.HasSuffix(p, "aliases.sh") {
|
||||
t.Errorf("AliasesPath should end with aliases.sh, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFishAliasesPath_UnderConfigDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
p := hook.FishAliasesPath()
|
||||
if !strings.HasPrefix(p, dir) {
|
||||
t.Errorf("FishAliasesPath should be under XDG_CONFIG_HOME, got %q", p)
|
||||
}
|
||||
if !strings.HasSuffix(p, "netssh.fish") {
|
||||
t.Errorf("FishAliasesPath should end with netssh.fish, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAliasFiles_ShFile verifies the bash/zsh file is always written.
|
||||
func TestWriteAliasFiles_ShFile(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CACHE_HOME")
|
||||
os.Setenv("XDG_CACHE_HOME", cacheDir)
|
||||
defer os.Setenv("XDG_CACHE_HOME", orig)
|
||||
|
||||
entries := []hook.AliasEntry{
|
||||
{Name: "web01", Host: "web01.example.com"},
|
||||
{Name: "db01", Host: "db01.example.com"},
|
||||
}
|
||||
if err := hook.WriteAliasFiles(entries); err != nil {
|
||||
t.Fatalf("WriteAliasFiles: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hook.AliasesPath())
|
||||
if err != nil {
|
||||
t.Fatalf("aliases.sh not created: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
for _, want := range []string{
|
||||
"alias web01='netssh web01.example.com'",
|
||||
"alias db01='netssh db01.example.com'",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("aliases.sh missing %q\ncontent:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAliasFiles_FishFile verifies the fish file is written when fish config dir exists.
|
||||
func TestWriteAliasFiles_FishFile(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", configDir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
// Create the fish config directory so WriteAliasFiles knows fish is set up.
|
||||
fishDir := filepath.Join(configDir, "fish")
|
||||
os.MkdirAll(fishDir, 0o755)
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
origCache := os.Getenv("XDG_CACHE_HOME")
|
||||
os.Setenv("XDG_CACHE_HOME", cacheDir)
|
||||
defer os.Setenv("XDG_CACHE_HOME", origCache)
|
||||
|
||||
entries := []hook.AliasEntry{{Name: "web01", Host: "web01.example.com"}}
|
||||
if err := hook.WriteAliasFiles(entries); err != nil {
|
||||
t.Fatalf("WriteAliasFiles: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hook.FishAliasesPath())
|
||||
if err != nil {
|
||||
t.Fatalf("netssh.fish not created: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "alias web01 'netssh web01.example.com'") {
|
||||
t.Errorf("fish alias file missing expected line\ncontent:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAliasFiles_SkipsFishWhenNotConfigured verifies that fish file is NOT written
|
||||
// when fish is not set up (no ~/.config/fish directory).
|
||||
func TestWriteAliasFiles_SkipsFishWhenNotConfigured(t *testing.T) {
|
||||
configDir := t.TempDir() // no fish subdir
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", configDir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
origCache := os.Getenv("XDG_CACHE_HOME")
|
||||
os.Setenv("XDG_CACHE_HOME", cacheDir)
|
||||
defer os.Setenv("XDG_CACHE_HOME", origCache)
|
||||
|
||||
entries := []hook.AliasEntry{{Name: "web01", Host: "web01.example.com"}}
|
||||
if err := hook.WriteAliasFiles(entries); err != nil {
|
||||
t.Fatalf("WriteAliasFiles: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(hook.FishAliasesPath()); err == nil {
|
||||
t.Error("fish alias file should not be created when fish is not configured")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAliasFiles_EmptyEntries verifies an empty (but valid) file is written.
|
||||
func TestWriteAliasFiles_EmptyEntries(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CACHE_HOME")
|
||||
os.Setenv("XDG_CACHE_HOME", cacheDir)
|
||||
defer os.Setenv("XDG_CACHE_HOME", orig)
|
||||
|
||||
if err := hook.WriteAliasFiles(nil); err != nil {
|
||||
t.Fatalf("WriteAliasFiles with nil entries: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(hook.AliasesPath())
|
||||
if err != nil {
|
||||
t.Fatalf("aliases.sh not created: %v", err)
|
||||
}
|
||||
// Should contain only the header comment, no alias lines.
|
||||
if strings.Contains(string(data), "alias ") {
|
||||
t.Error("aliases.sh should have no alias lines for empty entries")
|
||||
}
|
||||
}
|
||||
|
||||
// --- InstallAliasesSource ---
|
||||
|
||||
func TestInstallAliasesSource_Fresh(t *testing.T) {
|
||||
profile := writeProfile(t, "existing line\n")
|
||||
|
||||
installed, err := hook.InstallAliasesSource(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAliasesSource: %v", err)
|
||||
}
|
||||
if !installed {
|
||||
t.Error("should report installed=true on first install")
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
if !strings.Contains(string(data), hook.AliasesMarker) {
|
||||
t.Error("profile should contain aliases source line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAliasesSource_Idempotent(t *testing.T) {
|
||||
profile := writeProfile(t, "")
|
||||
|
||||
hook.InstallAliasesSource(profile)
|
||||
installed, err := hook.InstallAliasesSource(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("second InstallAliasesSource: %v", err)
|
||||
}
|
||||
if installed {
|
||||
t.Error("second call should report installed=false")
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
if count := strings.Count(string(data), hook.AliasesMarker); count != 1 {
|
||||
t.Errorf("aliases source line should appear once, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUninstall_AlsoRemovesAliasesSourceLine verifies that Uninstall removes both lines.
|
||||
func TestUninstall_AlsoRemovesAliasesSourceLine(t *testing.T) {
|
||||
content := "export PATH=$PATH\n" +
|
||||
hook.Line + "\n" +
|
||||
hook.AliasesSourceLine + "\n" +
|
||||
"export FOO=bar\n"
|
||||
profile := writeProfile(t, content)
|
||||
|
||||
removed, err := hook.Uninstall(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
if !removed {
|
||||
t.Error("should report removed=true")
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
s := string(data)
|
||||
if strings.Contains(s, hook.Marker) {
|
||||
t.Error("shell-init line should be removed")
|
||||
}
|
||||
if strings.Contains(s, hook.AliasesMarker) {
|
||||
t.Error("aliases source line should be removed")
|
||||
}
|
||||
if !strings.Contains(s, "export PATH") || !strings.Contains(s, "export FOO") {
|
||||
t.Error("other content should be preserved")
|
||||
}
|
||||
}
|
||||
+32
-5
@@ -8,10 +8,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Marker is the unique string used to detect an existing hook installation.
|
||||
// Marker is the unique string used to detect an existing hook line.
|
||||
Marker = "netssh shell-init"
|
||||
// Line is the full line written into the shell profile.
|
||||
Line = "netssh shell-init # netssh cache auto-refresh"
|
||||
|
||||
// AliasesMarker is the unique string used to detect the aliases source line.
|
||||
AliasesMarker = "netssh/aliases.sh"
|
||||
// AliasesSourceLine is the bash/zsh line that sources the generated aliases file.
|
||||
AliasesSourceLine = "source ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases"
|
||||
)
|
||||
|
||||
// ProfilePath returns the canonical shell profile path for the given shell name.
|
||||
@@ -68,8 +73,30 @@ func Install(profilePath string) (installed bool, err error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Uninstall removes the hook line from the profile.
|
||||
// Returns (true, nil) when removed, (false, nil) when the hook was not found.
|
||||
// InstallAliasesSource appends the aliases source line to a bash/zsh profile.
|
||||
// This is a no-op for fish (which uses conf.d auto-sourcing instead).
|
||||
// Returns (true, nil) when newly added, (false, nil) when already present.
|
||||
func InstallAliasesSource(profilePath string) (installed bool, err error) {
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return false, fmt.Errorf("reading profile: %w", err)
|
||||
}
|
||||
if strings.Contains(string(data), AliasesMarker) {
|
||||
return false, nil
|
||||
}
|
||||
f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("opening profile: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := fmt.Fprintf(f, "%s\n", AliasesSourceLine); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Uninstall removes the hook line and the aliases source line from the profile.
|
||||
// Returns (true, nil) when something was removed, (false, nil) when nothing was found.
|
||||
// Returns (false, nil) — not an error — when the file does not exist.
|
||||
func Uninstall(profilePath string) (removed bool, err error) {
|
||||
data, err := os.ReadFile(profilePath)
|
||||
@@ -80,13 +107,13 @@ func Uninstall(profilePath string) (removed bool, err error) {
|
||||
return false, fmt.Errorf("reading profile: %w", err)
|
||||
}
|
||||
content := string(data)
|
||||
if !strings.Contains(content, Marker) {
|
||||
if !strings.Contains(content, Marker) && !strings.Contains(content, AliasesMarker) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var kept []string
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if strings.Contains(line, Marker) {
|
||||
if strings.Contains(line, Marker) || strings.Contains(line, AliasesMarker) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
|
||||
Reference in New Issue
Block a user