Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66a49786c9 | ||
|
|
4b28d441b0 | ||
|
|
12cc64fdd9 | ||
|
|
2852dff2c4 | ||
|
|
2d11303014 | ||
|
|
5c6cc7dbbf | ||
|
|
b17ace7fc5 | ||
|
|
8241a064f4 | ||
|
|
86fc2f19eb | ||
|
|
fa646f25a6 |
No files matched your search
@@ -10,9 +10,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ netssh -p 2222 admin@app-server-03 uptime
|
|||||||
- **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments)
|
- **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments)
|
||||||
- **Recently-used list** — TUI opens with your 10 most-recently-connected hosts, no typing needed
|
- **Recently-used list** — TUI opens with your 10 most-recently-connected hosts, no typing needed
|
||||||
- **Tag/kind filter** — press `Ctrl+F` in the TUI to filter by `tag:prod` or `kind:vm`
|
- **Tag/kind filter** — press `Ctrl+F` in the TUI to filter by `tag:prod` or `kind:vm`
|
||||||
- **User/port override** — press `e` in the TUI to override the SSH user or port before connecting
|
- **User/port override** — press `Ctrl+E` in the TUI to override the SSH user or port before connecting
|
||||||
|
- **Server notes** — multiline local notes or optional NetBox comments, available in the TUI and CLI
|
||||||
- **Persistent cache** — successful lookups are cached to `~/.cache/netssh/hosts.json` for instant shell completion
|
- **Persistent cache** — successful lookups are cached to `~/.cache/netssh/hosts.json` for instant shell completion
|
||||||
- **Full pagination** — `cache refresh` fetches all hosts from NetBox (not just the first 50)
|
- **Full pagination** — `cache refresh` fetches all hosts from NetBox (not just the first 50)
|
||||||
- **Selective refresh** — `cache refresh --tag prod --kind vm` limits what gets synced
|
- **Selective refresh** — `cache refresh --tag prod --kind vm` limits what gets synced
|
||||||
@@ -45,9 +46,34 @@ The script detects your OS and architecture, downloads the matching binary from
|
|||||||
To install to a custom directory:
|
To install to a custom directory:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
INSTALL_DIR=~/.local/bin curl -fsSL https://git.zb-server.de/Sebi/ssh-netbox-wrapper/raw/branch/main/install.sh | bash
|
curl -fsSL https://git.zb-server.de/Sebi/ssh-netbox-wrapper/raw/branch/main/install.sh | INSTALL_DIR="$HOME/.local/bin" bash
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Updating an existing installation
|
||||||
|
|
||||||
|
Run the same one-liner again to update to the latest release. The installer uses
|
||||||
|
an existing `netssh` found in `PATH`, or `/usr/local/bin/netssh` for a new installation.
|
||||||
|
`INSTALL_DIR` explicitly overrides the destination. The replacement is atomic,
|
||||||
|
so an installed binary can be updated while it is running.
|
||||||
|
|
||||||
|
The installer checks all required programs before downloading or modifying files:
|
||||||
|
`curl`, `uname`, `sed`, `awk`, `mktemp`, `rm`, `dirname`, `readlink`, `mkdir`, `cat`,
|
||||||
|
`install`, `mv`, `cp`, `chmod`, `cmp`, and either `sha256sum` (Linux) or `shasum`
|
||||||
|
(macOS). Bash runs the installer; `sudo` is checked beforehand only if the target
|
||||||
|
directory requires elevated permissions. Missing programs are listed together;
|
||||||
|
install them with your package manager and retry. **Python is not required. SHA-256 verification is mandatory:** missing,
|
||||||
|
duplicate or mismatched checksums abort the update before replacing anything.
|
||||||
|
|
||||||
|
It also consolidates netssh integration in existing Bash, Zsh and Fish profiles
|
||||||
|
and creates the current shell's rc file when needed. Old standalone netssh hooks,
|
||||||
|
inline host aliases and `eval "$(netssh alias)"` entries are replaced by a single
|
||||||
|
managed block per rc file. Legacy entries in login profiles are removed as well.
|
||||||
|
Other shell settings, NetBox configuration, cache and local notes are preserved.
|
||||||
|
Every changed existing profile gets a `.netssh-backup.XXXXXX` backup; symlinks
|
||||||
|
and permissions are preserved. `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and `ZDOTDIR`
|
||||||
|
are respected. Alias files and installed shell completions are regenerated.
|
||||||
|
Open a **new terminal** after updating to load the new aliases and hooks.
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -67,7 +93,7 @@ Re-run it at any time to change settings without editing the file manually:
|
|||||||
netssh configure
|
netssh configure
|
||||||
```
|
```
|
||||||
|
|
||||||
The wizard walks through NetBox connection, SSH defaults, resolver strategies, and cache TTL,
|
The wizard walks through NetBox connection, SSH defaults, resolver strategies, cache TTL, and an optional command alias,
|
||||||
then saves to `~/.config/netssh.yaml`.
|
then saves to `~/.config/netssh.yaml`.
|
||||||
|
|
||||||
### Manual config
|
### Manual config
|
||||||
@@ -108,6 +134,31 @@ shortcuts:
|
|||||||
|
|
||||||
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
||||||
|
|
||||||
|
### Short command alias
|
||||||
|
|
||||||
|
In `netssh configure`, set **Command alias** to `j` to use `j` anywhere you would
|
||||||
|
use `netssh`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
j # interactive server search
|
||||||
|
j server01 # connect to a server
|
||||||
|
j notes show server01
|
||||||
|
```
|
||||||
|
|
||||||
|
The setting is saved as:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
shell:
|
||||||
|
alias: j
|
||||||
|
```
|
||||||
|
|
||||||
|
The wizard regenerates shell aliases and installs the current shell's hook when
|
||||||
|
an alias is set. Open a new terminal (or follow the displayed reload command).
|
||||||
|
Changing the setting replaces the old generated alias; leaving it empty disables
|
||||||
|
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:
|
||||||
|
`NETSSH_SHELL_ALIAS=j`.
|
||||||
|
|
||||||
### API tokens
|
### API tokens
|
||||||
|
|
||||||
NetBox supports two token formats:
|
NetBox supports two token formats:
|
||||||
@@ -235,7 +286,9 @@ The TUI opens with your 10 most-recently-connected hosts. Start typing to search
|
|||||||
| `Tab` | autocomplete top result into the search field |
|
| `Tab` | autocomplete top result into the search field |
|
||||||
| `↑` / `↓` | navigate results |
|
| `↑` / `↓` | navigate results |
|
||||||
| `Enter` | connect to selected host |
|
| `Enter` | connect to selected host |
|
||||||
| `e` | open inline editor to override user/port before connecting |
|
| `Ctrl+E` | open inline editor to override user/port before connecting |
|
||||||
|
| `Ctrl+N` | edit local notes for the selected server |
|
||||||
|
| `Ctrl+B` | edit NetBox comments for the selected server |
|
||||||
| `Ctrl+F` | open/close tag and kind filter (`tag:prod kind:vm`) |
|
| `Ctrl+F` | open/close tag and kind filter (`tag:prod kind:vm`) |
|
||||||
| `Esc` / `Ctrl+C` | quit (or close filter/edit if open) |
|
| `Esc` / `Ctrl+C` | quit (or close filter/edit if open) |
|
||||||
|
|
||||||
@@ -247,13 +300,48 @@ Filter: tag:prod kind:vm
|
|||||||
|
|
||||||
Multiple `tag:` values are AND-combined. The filter is applied locally against the cache; when doing a live NetBox search the first tag is also forwarded as a query parameter.
|
Multiple `tag:` values are AND-combined. The filter is applied locally against the cache; when doing a live NetBox search the first tag is also forwarded as a query parameter.
|
||||||
|
|
||||||
**User/port override** — press `e` on any highlighted host:
|
**User/port override** — press `Ctrl+E` on any highlighted host:
|
||||||
|
|
||||||
```
|
```
|
||||||
Connect as: admin@my-router:22
|
Connect as: admin@my-router:22
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit the pre-filled value and press `Enter` to connect. `Esc` cancels. Port 22 is treated as default and omitted from the ssh command.
|
Edit the pre-filled value and press `Enter` to connect. `Esc` cancels. The configured default port is pre-filled. An explicit port 22 overrides a different configured default.
|
||||||
|
|
||||||
|
### Server notes
|
||||||
|
|
||||||
|
Select a server in the TUI and press `Ctrl+N` for local notes, or `Ctrl+B` for
|
||||||
|
NetBox comments. Both open a multiline editor: `Ctrl+S` saves, `Esc` cancels.
|
||||||
|
Existing text is loaded before editing. A failed save keeps your draft in the editor.
|
||||||
|
Local notes and NetBox comments are independent; there is no automatic synchronization.
|
||||||
|
|
||||||
|
The CLI also supports reading, replacing, and appending notes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh notes set web01 "Maintenance on Friday"
|
||||||
|
netssh notes append web01 "Reboot completed"
|
||||||
|
netssh notes show web01
|
||||||
|
printf 'First line\nSecond line\n' | netssh notes set web01
|
||||||
|
netssh notes append web01 --netbox --kind vm "Maintenance completed"
|
||||||
|
netssh notes show web01 --netbox --kind vm
|
||||||
|
```
|
||||||
|
|
||||||
|
`set` replaces the selected note; `append` preserves existing text and adds a new line.
|
||||||
|
Omitting the text reads it from standard input. An empty string clears the note.
|
||||||
|
For an uncached local host, specify `--kind device` or `--kind vm`; local operations
|
||||||
|
work offline. Use the full server name as displayed in the TUI.
|
||||||
|
|
||||||
|
Local notes live under `~/.config/netssh/notes/` (`$XDG_CONFIG_HOME` is respected),
|
||||||
|
with file permissions `0600` and atomic file replacement. They are keyed by NetBox
|
||||||
|
URL, host kind, and hostname, so cache refreshes and `cache clear` preserve them.
|
||||||
|
Changing the hostname or NetBox URL creates a separate note identity.
|
||||||
|
|
||||||
|
`--netbox` explicitly selects the device/VM's shared `comments` field and requires
|
||||||
|
a token with write permissions. Existing comments are loaded before modification;
|
||||||
|
ambiguous names are rejected (specify `--kind` where appropriate).
|
||||||
|
The client uses the API's ETag/If-Match conflict protection when the server provides
|
||||||
|
it; older NetBox versions without ETags use the server's last-write-wins behavior.
|
||||||
|
See the [NetBox REST API documentation](https://netbox.readthedocs.io/en/stable/integrations/rest-api/).
|
||||||
|
|
||||||
### Cache management
|
### Cache management
|
||||||
|
|
||||||
@@ -329,7 +417,12 @@ Completions are served from the local cache — no network request on every `<Ta
|
|||||||
|
|
||||||
## Shell Hook
|
## 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
|
### Install
|
||||||
|
|
||||||
@@ -340,21 +433,38 @@ netssh hook install --shell zsh
|
|||||||
netssh hook install --shell fish
|
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
|
netssh shell-init # netssh cache auto-refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
| Shell | Profile file |
|
Fish alias file (`~/.config/fish/conf.d/netssh.fish`) is written automatically and sourced by fish on every shell start without any profile changes.
|
||||||
|-------|-------------|
|
|
||||||
| bash | `~/.bashrc` |
|
|
||||||
| zsh | `~/.zshrc` |
|
|
||||||
| fish | `~/.config/fish/config.fish` |
|
|
||||||
|
|
||||||
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
|
```sh
|
||||||
source ~/.bashrc # bash
|
source ~/.bashrc # bash
|
||||||
@@ -362,6 +472,18 @@ source ~/.zshrc # zsh
|
|||||||
source ~/.config/fish/config.fish # fish
|
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
|
### Uninstall
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -369,21 +491,23 @@ netssh hook uninstall # auto-detects $SHELL
|
|||||||
netssh hook uninstall --shell zsh
|
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 |
|
| Trigger | When it fires |
|
||||||
|---------|--------------|
|
|---------|--------------|
|
||||||
| Connect / TUI start | On the next SSH command or `netssh` TUI after 24 h |
|
| SSH connect / TUI start | On the next `netssh` call after 24 h have elapsed |
|
||||||
| Shell hook | On the first new shell session after 24 h |
|
| 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
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./... # run all tests
|
go test ./... # includes offline installer/update tests
|
||||||
go build ./... # build all packages
|
go build ./... # build all packages
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+98
-47
@@ -19,6 +19,7 @@ import (
|
|||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
"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/hook"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
"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/resolver"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
||||||
@@ -29,6 +30,7 @@ import (
|
|||||||
// managedSubcommands are dispatched to cobra; everything else is treated as SSH wrapper mode.
|
// managedSubcommands are dispatched to cobra; everything else is treated as SSH wrapper mode.
|
||||||
var managedSubcommands = map[string]bool{
|
var managedSubcommands = map[string]bool{
|
||||||
"configure": true,
|
"configure": true,
|
||||||
|
"notes": true,
|
||||||
"search": true,
|
"search": true,
|
||||||
"cache": true,
|
"cache": true,
|
||||||
"alias": true,
|
"alias": true,
|
||||||
@@ -213,7 +215,8 @@ func runTUI() {
|
|||||||
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
m := tui.New(nbClient, c, cfg.SSH.DefaultUser)
|
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
|
||||||
|
m.SetNotes(notes.New(cfg.NetBox.URL, nbClient))
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
final, err := p.Run()
|
final, err := p.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -229,6 +232,29 @@ func runTUI() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Revalidate stale or live TUI results through the configured resolver chain.
|
||||||
|
if cached, fresh := c.Get(host.Name); fresh {
|
||||||
|
host.IP = cached.IP
|
||||||
|
} 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.Name, IP: host.IP, Kind: target.Kind, Tags: target.Tags})
|
||||||
|
}
|
||||||
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
||||||
|
|
||||||
var sshArgs []string
|
var sshArgs []string
|
||||||
@@ -289,7 +315,7 @@ func rootCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
root.AddCommand(configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd())
|
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd())
|
||||||
|
|
||||||
// cobra builds the "completion" command lazily; force init so we can extend it.
|
// cobra builds the "completion" command lazily; force init so we can extend it.
|
||||||
root.InitDefaultCompletionCmd()
|
root.InitDefaultCompletionCmd()
|
||||||
@@ -340,8 +366,7 @@ func completionInstallCmd(root *cobra.Command) *cobra.Command {
|
|||||||
}
|
}
|
||||||
note = "Make sure ~/.zfunc is in your fpath:\n fpath=(~/.zfunc $fpath)\n autoload -Uz compinit && compinit"
|
note = "Make sure ~/.zfunc is in your fpath:\n fpath=(~/.zfunc $fpath)\n autoload -Uz compinit && compinit"
|
||||||
case "fish":
|
case "fish":
|
||||||
configDir, _ := os.UserConfigDir()
|
dir = filepath.Join(hook.FishConfigDir(), "completions")
|
||||||
dir = filepath.Join(configDir, "fish", "completions")
|
|
||||||
file = filepath.Join(dir, "netssh.fish")
|
file = filepath.Join(dir, "netssh.fish")
|
||||||
gen = func() ([]byte, error) {
|
gen = func() ([]byte, error) {
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
@@ -493,7 +518,11 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
|
|
||||||
chain, _ := resolver.New(cfg.Resolver)
|
chain, err := resolver.New(cfg.Resolver)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolver: %w", err)
|
||||||
|
}
|
||||||
|
written := 0
|
||||||
for i := range entries {
|
for i := range entries {
|
||||||
e := &entries[i]
|
e := &entries[i]
|
||||||
|
|
||||||
@@ -512,16 +541,24 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
|
|
||||||
if ip != "" {
|
if ip != "" {
|
||||||
c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags})
|
c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags})
|
||||||
|
written++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.Save(); err != nil {
|
if err := c.Save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := c.SetRefreshed(); err != nil {
|
if filterTag == "" && filterKind == "" {
|
||||||
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
|
if err := c.SetRefreshed(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("%d entries written to cache.\n", len(entries))
|
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
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -530,6 +567,11 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
return cmd
|
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 {
|
func aliasCmd() *cobra.Command {
|
||||||
var shell string
|
var shell string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -558,46 +600,23 @@ Or use in a script:
|
|||||||
shell = filepath.Base(os.Getenv("SHELL"))
|
shell = filepath.Base(os.Getenv("SHELL"))
|
||||||
}
|
}
|
||||||
|
|
||||||
entries := c.All()
|
entries := buildAliasEntries(c, *cfg)
|
||||||
sort.Slice(entries, func(i, j int) bool {
|
if len(entries) == 0 {
|
||||||
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 {
|
|
||||||
fmt.Fprintln(os.Stderr, "netssh: cache is empty — run 'netssh cache refresh' first")
|
fmt.Fprintln(os.Stderr, "netssh: cache is empty — run 'netssh cache refresh' first")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch shell {
|
switch shell {
|
||||||
case "fish":
|
case "fish":
|
||||||
fmt.Printf("# netssh aliases (%d hosts) — source with: netssh alias --shell fish | source\n", len(lines))
|
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:
|
default:
|
||||||
fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(lines))
|
fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(entries))
|
||||||
}
|
for _, e := range entries {
|
||||||
for _, l := range lines {
|
fmt.Println(e.Definition("bash"))
|
||||||
fmt.Println(l)
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -606,19 +625,25 @@ Or use in a script:
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// shellInitCmd is called at shell startup to trigger a background cache refresh when stale.
|
// shellInitCmd is called at shell startup to update alias files and trigger a
|
||||||
// It is intentionally silent — no output on success so it never disrupts shell startup.
|
// background cache refresh when stale. Intentionally silent — never disrupts the prompt.
|
||||||
func shellInitCmd() *cobra.Command {
|
func shellInitCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "shell-init",
|
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,
|
Hidden: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil || cfg.NetBox.URL == "" {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
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.
|
||||||
|
_ = hook.WriteAliasFiles(buildAliasEntries(c, *cfg))
|
||||||
|
|
||||||
maybeBackgroundRefresh(cfg, c)
|
maybeBackgroundRefresh(cfg, c)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -652,15 +677,41 @@ After installation, reload your profile or open a new shell.`,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
installed, err := hook.Install(profile)
|
installed, err := hook.Install(profile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if !installed {
|
||||||
fmt.Printf("Hook already installed in %s\n", profile)
|
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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("Hook installed → %s\n%s\n", profile, hook.ReloadNote(profile))
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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/netbox"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func notesCmd() *cobra.Command {
|
||||||
|
var remote bool
|
||||||
|
var kind string
|
||||||
|
cmd := &cobra.Command{Use: "notes", Short: "Read and write server notes (local by default)"}
|
||||||
|
cmd.PersistentFlags().BoolVar(&remote, "netbox", false, "Use the NetBox comments field instead of local notes")
|
||||||
|
cmd.PersistentFlags().StringVar(&kind, "kind", "", "Host kind: device or vm (required for uncached local hosts)")
|
||||||
|
for _, action := range []string{"show", "set", "append"} {
|
||||||
|
sub := &cobra.Command{Use: action + " <host> [text]", Short: action + " a server note", Args: cobra.RangeArgs(1, 2)}
|
||||||
|
if action == "show" {
|
||||||
|
sub.Args = cobra.ExactArgs(1)
|
||||||
|
}
|
||||||
|
sub.RunE = func(cmd *cobra.Command, args []string) error {
|
||||||
|
if kind != "" && kind != "vm" && kind != "device" {
|
||||||
|
return fmt.Errorf("invalid kind %q", kind)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var client *netbox.Client
|
||||||
|
if cfg.NetBox.URL != "" {
|
||||||
|
client = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||||
|
}
|
||||||
|
host := netbox.HostEntry{Name: args[0], Kind: kind}
|
||||||
|
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var cached []cache.Entry
|
||||||
|
if !remote {
|
||||||
|
cached = c.All()
|
||||||
|
}
|
||||||
|
for _, entry := range cached {
|
||||||
|
if strings.EqualFold(entry.Name, host.Name) && (kind == "" || kind == entry.Kind) {
|
||||||
|
host.Name = entry.Name
|
||||||
|
host.Kind = entry.Kind
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !remote && host.Kind == "" {
|
||||||
|
return fmt.Errorf("host is not cached; specify --kind device or --kind vm")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
service := notes.New(cfg.NetBox.URL, client)
|
||||||
|
note, err := service.Read(ctx, host, remote)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if action == "show" {
|
||||||
|
_, err := fmt.Fprint(cmd.OutOrStdout(), note.Text)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var text string
|
||||||
|
if len(args) == 2 {
|
||||||
|
text = args[1]
|
||||||
|
} else {
|
||||||
|
data, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1024*1024+1))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(data) > 1024*1024 {
|
||||||
|
return fmt.Errorf("note exceeds 1 MiB")
|
||||||
|
}
|
||||||
|
text = string(data)
|
||||||
|
}
|
||||||
|
if action == "append" && note.Text != "" {
|
||||||
|
note.Text = strings.TrimRight(note.Text, "\n") + "\n" + text
|
||||||
|
} else {
|
||||||
|
note.Text = text
|
||||||
|
}
|
||||||
|
return service.Write(ctx, note, remote)
|
||||||
|
}
|
||||||
|
cmd.AddCommand(sub)
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLocalNotesCLI(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
|
||||||
|
run := func(args []string, input string) (string, error) {
|
||||||
|
root := rootCmd()
|
||||||
|
var out bytes.Buffer
|
||||||
|
root.SetOut(&out)
|
||||||
|
root.SetErr(&out)
|
||||||
|
root.SetIn(strings.NewReader(input))
|
||||||
|
root.SetArgs(args)
|
||||||
|
err := root.Execute()
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
if _, err := run([]string{"notes", "set", "web01", "--kind", "vm"}, "First\nSecond"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := run([]string{"notes", "append", "web01", "Third", "--kind", "vm"}, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := run([]string{"notes", "show", "web01", "--kind", "vm"}, "")
|
||||||
|
if err != nil || out != "First\nSecond\nThird" {
|
||||||
|
t.Fatalf("show: %q, %v", out, err)
|
||||||
|
}
|
||||||
|
if _, err := run([]string{"notes", "set", "web01", "", "--kind", "vm"}, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err = run([]string{"notes", "show", "web01", "--kind", "vm"}, "")
|
||||||
|
if err != nil || out != "" {
|
||||||
|
t.Fatalf("clear: %q, %v", out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellInitUpdatesAndRemovesCommandAliasWithoutHosts(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||||
|
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
|
||||||
|
t.Setenv("NETSSH_NETBOX_URL", "")
|
||||||
|
t.Setenv("NETSSH_SHELL_ALIAS", "j")
|
||||||
|
run := func() {
|
||||||
|
t.Helper()
|
||||||
|
root := rootCmd()
|
||||||
|
root.SetArgs([]string{"shell-init"})
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run()
|
||||||
|
data, err := os.ReadFile(hook.AliasesPath())
|
||||||
|
if err != nil || !strings.Contains(string(data), "alias j='netssh'") {
|
||||||
|
t.Fatalf("alias missing: %s, %v", data, err)
|
||||||
|
}
|
||||||
|
t.Setenv("NETSSH_SHELL_ALIAS", "k")
|
||||||
|
run()
|
||||||
|
data, _ = os.ReadFile(hook.AliasesPath())
|
||||||
|
if strings.Contains(string(data), "alias j=") || !strings.Contains(string(data), "alias k='netssh'") {
|
||||||
|
t.Fatalf("alias change: %s", data)
|
||||||
|
}
|
||||||
|
t.Setenv("NETSSH_SHELL_ALIAS", "")
|
||||||
|
run()
|
||||||
|
data, _ = os.ReadFile(hook.AliasesPath())
|
||||||
|
if strings.Contains(string(data), "alias k=") {
|
||||||
|
t.Fatalf("stale alias: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
+221
-75
@@ -1,94 +1,240 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -eo pipefail
|
||||||
|
# Bash 3 (macOS) treats empty arrays as unset under nounset.
|
||||||
|
: "${HOME:?HOME must be set}"
|
||||||
|
|
||||||
REPO="Sebi/ssh-netbox-wrapper"
|
REPO="Sebi/ssh-netbox-wrapper"
|
||||||
BASE_URL="https://git.zb-server.de"
|
BASE_URL="https://git.zb-server.de"
|
||||||
BINARY="netssh"
|
BINARY="netssh"
|
||||||
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
|
info() { printf ' %s\n' "$*"; }
|
||||||
|
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
||||||
|
need() { command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not installed"; }
|
||||||
|
# Check the complete toolchain before any network request or filesystem change.
|
||||||
|
MISSING=()
|
||||||
|
for program in curl uname sed awk mktemp rm dirname readlink mkdir cat install mv cp chmod cmp; do
|
||||||
|
if ! command -v "$program" >/dev/null 2>&1; then MISSING+=("$program"); fi
|
||||||
|
done
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
SHA256=(sha256sum)
|
||||||
|
elif command -v shasum >/dev/null 2>&1; then
|
||||||
|
SHA256=(shasum -a 256)
|
||||||
|
else
|
||||||
|
MISSING+=("sha256sum or shasum (SHA-256 verification)")
|
||||||
|
fi
|
||||||
|
if [[ ${#MISSING[@]} -gt 0 ]]; then
|
||||||
|
printf 'error: required programs are missing:\n' >&2
|
||||||
|
printf ' - %s\n' "${MISSING[@]}" >&2
|
||||||
|
die "install these dependencies using your package manager, then run the installer again"
|
||||||
|
fi
|
||||||
|
|
||||||
# --- helpers -----------------------------------------------------------------
|
# Re-running the one-liner updates the existing installation in its current path.
|
||||||
|
if [[ -z "${INSTALL_DIR:-}" ]]; then
|
||||||
red() { printf '\033[31m%s\033[0m\n' "$*"; }
|
EXISTING=$(command -v "$BINARY" || true)
|
||||||
green() { printf '\033[32m%s\033[0m\n' "$*"; }
|
if [[ "$EXISTING" == /* && -f "$EXISTING" ]]; then
|
||||||
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
|
INSTALL_DIR=$(dirname "$EXISTING")
|
||||||
info() { printf ' %s\n' "$*"; }
|
else
|
||||||
|
INSTALL_DIR=/usr/local/bin
|
||||||
die() { red "error: $*" >&2; exit 1; }
|
fi
|
||||||
|
fi
|
||||||
need() {
|
[[ "$INSTALL_DIR" == /* ]] || INSTALL_DIR="$PWD/$INSTALL_DIR"
|
||||||
command -v "$1" &>/dev/null || die "'$1' is required but not installed"
|
# Check sudo only when the destination needs elevated permissions, also before
|
||||||
}
|
# downloading. For a new directory, inspect its nearest existing parent.
|
||||||
|
PRIVILEGE=()
|
||||||
# --- detect OS / arch --------------------------------------------------------
|
INSTALL_PARENT=$INSTALL_DIR
|
||||||
|
while [[ ! -e "$INSTALL_PARENT" ]]; do INSTALL_PARENT=$(dirname "$INSTALL_PARENT"); done
|
||||||
detect_os() {
|
[[ -d "$INSTALL_PARENT" ]] || die "installation path is not a directory: $INSTALL_PARENT"
|
||||||
case "$(uname -s)" in
|
if [[ ! -w "$INSTALL_PARENT" || ! -x "$INSTALL_PARENT" ]]; then
|
||||||
Linux) echo linux ;;
|
need sudo
|
||||||
Darwin) echo darwin ;;
|
PRIVILEGE=(sudo)
|
||||||
*) die "unsupported OS: $(uname -s)" ;;
|
fi
|
||||||
esac
|
case "$(uname -s)" in
|
||||||
}
|
Linux) PLATFORM_OS=linux ;;
|
||||||
|
Darwin) PLATFORM_OS=darwin ;;
|
||||||
detect_arch() {
|
*) die "unsupported OS: $(uname -s)" ;;
|
||||||
case "$(uname -m)" in
|
esac
|
||||||
x86_64|amd64) echo amd64 ;;
|
case "$(uname -m)" in
|
||||||
aarch64|arm64) echo arm64 ;;
|
x86_64|amd64) PLATFORM_ARCH=amd64 ;;
|
||||||
*) die "unsupported architecture: $(uname -m)" ;;
|
aarch64|arm64) PLATFORM_ARCH=arm64 ;;
|
||||||
esac
|
*) die "unsupported architecture: $(uname -m)" ;;
|
||||||
}
|
esac
|
||||||
|
printf 'netssh install / update\n'
|
||||||
# --- main --------------------------------------------------------------------
|
info "platform : $PLATFORM_OS/$PLATFORM_ARCH"
|
||||||
|
|
||||||
need curl
|
|
||||||
|
|
||||||
OS=$(detect_os)
|
|
||||||
ARCH=$(detect_arch)
|
|
||||||
|
|
||||||
bold "netssh installer"
|
|
||||||
info "platform : $OS/$ARCH"
|
|
||||||
info "target : $INSTALL_DIR/$BINARY"
|
info "target : $INSTALL_DIR/$BINARY"
|
||||||
|
|
||||||
# Fetch the latest release tag from the Gitea API.
|
|
||||||
API_URL="$BASE_URL/api/v1/repos/$REPO/releases/latest"
|
API_URL="$BASE_URL/api/v1/repos/$REPO/releases/latest"
|
||||||
TAG=$(curl -sf "$API_URL" | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])") \
|
RELEASE=$(curl -fsSL "$API_URL") || die "could not fetch latest release from $API_URL"
|
||||||
|| die "could not fetch latest release from $API_URL"
|
TAG=$(printf '%s\n' "$RELEASE" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||||
|
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.+-][a-zA-Z0-9.+-]+)?$ ]] || die "invalid or missing release tag"
|
||||||
ASSET="${BINARY}_${OS}_${ARCH}"
|
ASSET="${BINARY}_${PLATFORM_OS}_${PLATFORM_ARCH}"
|
||||||
DOWNLOAD_URL="$BASE_URL/$REPO/releases/download/$TAG/$ASSET"
|
DOWNLOAD_URL="$BASE_URL/$REPO/releases/download/$TAG"
|
||||||
CHECKSUM_URL="$BASE_URL/$REPO/releases/download/$TAG/checksums.txt"
|
|
||||||
|
|
||||||
info "version : $TAG"
|
info "version : $TAG"
|
||||||
echo
|
|
||||||
|
|
||||||
# Download binary and checksums into a temp directory.
|
|
||||||
TMP=$(mktemp -d)
|
TMP=$(mktemp -d)
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
STAGED=""
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMP"
|
||||||
|
if [[ -n "$STAGED" ]]; then "${PRIVILEGE[@]}" rm -f "$STAGED"; fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
info "downloading $ASSET..."
|
info "downloading $ASSET..."
|
||||||
curl -fL --progress-bar -o "$TMP/$ASSET" "$DOWNLOAD_URL"
|
curl -fL --progress-bar -o "$TMP/$ASSET" "$DOWNLOAD_URL/$ASSET"
|
||||||
|
curl -fsSL -o "$TMP/checksums.txt" "$DOWNLOAD_URL/checksums.txt" \
|
||||||
|
|| die "could not fetch checksums; installation aborted"
|
||||||
|
EXPECTED=$(awk -v asset="$ASSET" '$2 == asset || $2 == "*" asset {print tolower($1)}' "$TMP/checksums.txt")
|
||||||
|
[[ "$EXPECTED" =~ ^[a-f0-9]{64}$ ]] || die "missing, duplicate, or invalid checksum; installation aborted"
|
||||||
|
ACTUAL=$("${SHA256[@]}" "$TMP/$ASSET")
|
||||||
|
ACTUAL=${ACTUAL%% *}
|
||||||
|
[[ "$ACTUAL" == "$EXPECTED" ]] || die "checksum mismatch; installation aborted"
|
||||||
|
info "checksum OK"
|
||||||
|
|
||||||
info "verifying checksum..."
|
# Only standalone commands and our managed blocks are removed. Unrelated content
|
||||||
curl -sf -o "$TMP/checksums.txt" "$CHECKSUM_URL" \
|
# and commands which merely mention netssh are retained.
|
||||||
|| { info "warning: could not fetch checksums, skipping verification"; }
|
cat > "$TMP/clean-profile.awk" <<'AWK'
|
||||||
|
function legacy(s) {
|
||||||
|
if (s ~ /^([^ \t]*\/)?netssh[ \t]+shell-init[ \t]*(#.*)?$/) return 1
|
||||||
|
if (s ~ /^(source|\.)[ \t]+["']?[^;|&]*netssh\/aliases\.sh["']?([ \t]+2>\/dev\/null)?[ \t]*(#.*)?$/) return 1
|
||||||
|
if (s ~ /^eval[ \t]+["']?\$\(([^ \t]*\/)?netssh[ \t]+alias([ \t]+--shell[ \t]+(bash|zsh))?\)["']?[ \t]*(#.*)?$/) return 1
|
||||||
|
if (s ~ /^([^ \t]*\/)?netssh[ \t]+alias([ \t]+--shell[ \t]+fish)?[ \t]*\|[ \t]*source[ \t]*(#.*)?$/) return 1
|
||||||
|
if (s ~ /^source[ \t]+<\(([^ \t]*\/)?netssh[ \t]+alias\)[ \t]*(#.*)?$/) return 1
|
||||||
|
if (s ~ /^alias[ \t]+[A-Za-z0-9_.-]+(=|[ \t]+)["']netssh([ \t]+[^"']+)?["'][ \t]*(#.*)?$/) return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
{
|
||||||
|
s = $0
|
||||||
|
sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s)
|
||||||
|
if (s == "# >>> netssh >>>") { if (inside) bad = 1; inside = 1; next }
|
||||||
|
if (s == "# <<< netssh <<<") { if (!inside) bad = 1; inside = 0; next }
|
||||||
|
if (!inside && !legacy(s)) print $0
|
||||||
|
}
|
||||||
|
END { if (inside || bad) exit 1 }
|
||||||
|
AWK
|
||||||
|
|
||||||
if [[ -f "$TMP/checksums.txt" ]]; then
|
# POSIX-style single quoting also works for these paths in fish, except embedded
|
||||||
# checksums.txt was built with `sha256sum` in the dist/ dir, so entries look
|
# quotes, which fish escapes with a backslash instead of concatenated quotes.
|
||||||
# like "abc123 netssh_linux_amd64". We need to cd into the temp dir first.
|
quote_sh() { printf "'%s'" "${1//\'/\'\\\'\'}"; }
|
||||||
(cd "$TMP" && grep "$ASSET" checksums.txt | sha256sum --check --status) \
|
quote_fish() {
|
||||||
|| die "checksum mismatch — aborting installation"
|
local value=${1//\\/\\\\}
|
||||||
info "checksum OK"
|
value=${value//\'/\\\'}
|
||||||
fi
|
printf "'%s'" "$value"
|
||||||
|
}
|
||||||
|
resolve_profile() {
|
||||||
|
local path=$1 target steps=0
|
||||||
|
while [[ -L "$path" ]]; do
|
||||||
|
steps=$((steps + 1))
|
||||||
|
[[ "$steps" -le 40 ]] || die "symlink loop in $1"
|
||||||
|
target=$(readlink "$path")
|
||||||
|
if [[ "$target" == /* ]]; then path=$target; else path="$(dirname "$path")/$target"; fi
|
||||||
|
done
|
||||||
|
printf '%s\n' "$path"
|
||||||
|
}
|
||||||
|
|
||||||
chmod +x "$TMP/$ASSET"
|
PROFILE_PATHS=()
|
||||||
|
PROFILE_TARGETS=()
|
||||||
# Install — try without sudo first, fall back if needed.
|
PROFILE_DATA=()
|
||||||
if [[ -w "$INSTALL_DIR" ]]; then
|
SHELLS=()
|
||||||
mv "$TMP/$ASSET" "$INSTALL_DIR/$BINARY"
|
CURRENT_SHELL=${SHELL:-}
|
||||||
|
CURRENT_SHELL=${CURRENT_SHELL##*/}
|
||||||
|
CONFIG_DIR=${XDG_CONFIG_HOME:-$HOME/.config}
|
||||||
|
if [[ "$PLATFORM_OS" == darwin ]]; then
|
||||||
|
CACHE_DIR=${XDG_CACHE_HOME:-$HOME/Library/Caches}
|
||||||
else
|
else
|
||||||
info "sudo required to write to $INSTALL_DIR"
|
CACHE_DIR=${XDG_CACHE_HOME:-$HOME/.cache}
|
||||||
sudo mv "$TMP/$ASSET" "$INSTALL_DIR/$BINARY"
|
|
||||||
fi
|
fi
|
||||||
|
ZSH_DIR=${ZDOTDIR:-$HOME}
|
||||||
|
plan_profile() {
|
||||||
|
local path=$1 kind=$2 target data previous count
|
||||||
|
if [[ ! -e "$path" && ! -L "$path" ]] && { [[ -z "$kind" ]] || [[ "$kind" != "$CURRENT_SHELL" ]]; }; then return; fi
|
||||||
|
target=$(resolve_profile "$path")
|
||||||
|
# A dotfile manager may point two profile names at the same file.
|
||||||
|
for previous in "${PROFILE_TARGETS[@]}"; do [[ "$previous" != "$target" ]] || return 0; done
|
||||||
|
data="$TMP/profile-${#PROFILE_PATHS[@]}"
|
||||||
|
if [[ -e "$target" ]]; then
|
||||||
|
awk -f "$TMP/clean-profile.awk" "$target" > "$data" \
|
||||||
|
|| die "unclosed or nested netssh block in $path; profiles were not changed"
|
||||||
|
else
|
||||||
|
: > "$data"
|
||||||
|
fi
|
||||||
|
if [[ -n "$kind" ]]; then
|
||||||
|
count=0
|
||||||
|
for previous in "${SHELLS[@]}"; do [[ "$previous" != "$kind" ]] || count=1; done
|
||||||
|
if [[ "$count" == 0 ]]; then SHELLS+=("$kind"); fi
|
||||||
|
{
|
||||||
|
printf '# >>> netssh >>>\n'
|
||||||
|
if [[ "$kind" == fish ]]; then
|
||||||
|
printf 'set -gx PATH %s $PATH\n' "$(quote_fish "$INSTALL_DIR")"
|
||||||
|
printf '%s shell-init\n' "$(quote_fish "$INSTALL_DIR/$BINARY")"
|
||||||
|
else
|
||||||
|
printf 'export PATH=%s:"$PATH"\n' "$(quote_sh "$INSTALL_DIR")"
|
||||||
|
printf '%s shell-init\n' "$(quote_sh "$INSTALL_DIR/$BINARY")"
|
||||||
|
printf '[ ! -f %s ] || . %s\n' "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")" "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")"
|
||||||
|
fi
|
||||||
|
printf '# <<< netssh <<<\n'
|
||||||
|
} >> "$data"
|
||||||
|
fi
|
||||||
|
PROFILE_PATHS+=("$path"); PROFILE_TARGETS+=("$target"); PROFILE_DATA+=("$data")
|
||||||
|
}
|
||||||
|
# Preflight every profile before installing the binary or modifying any profile.
|
||||||
|
plan_profile "$HOME/.bashrc" bash
|
||||||
|
plan_profile "$HOME/.bash_profile" ""
|
||||||
|
plan_profile "$HOME/.bash_login" ""
|
||||||
|
plan_profile "$HOME/.profile" ""
|
||||||
|
plan_profile "$ZSH_DIR/.zshrc" zsh
|
||||||
|
plan_profile "$ZSH_DIR/.zprofile" ""
|
||||||
|
plan_profile "$ZSH_DIR/.zshenv" ""
|
||||||
|
if [[ "$ZSH_DIR" != "$HOME" ]]; then
|
||||||
|
plan_profile "$HOME/.zshrc" ""
|
||||||
|
plan_profile "$HOME/.zprofile" ""
|
||||||
|
plan_profile "$HOME/.zshenv" ""
|
||||||
|
fi
|
||||||
|
plan_profile "$CONFIG_DIR/fish/config.fish" fish
|
||||||
|
|
||||||
echo
|
if [[ ${#PRIVILEGE[@]} -gt 0 ]]; then
|
||||||
green "✓ netssh $TAG installed to $INSTALL_DIR/$BINARY"
|
info "sudo required to write to $INSTALL_DIR"
|
||||||
info "run 'netssh --help' to get started"
|
fi
|
||||||
|
"${PRIVILEGE[@]}" mkdir -p "$INSTALL_DIR"
|
||||||
|
# Stage on the destination filesystem to replace a running binary atomically.
|
||||||
|
STAGED=$("${PRIVILEGE[@]}" mktemp "$INSTALL_DIR/.netssh.XXXXXX")
|
||||||
|
"${PRIVILEGE[@]}" install -m 755 "$TMP/$ASSET" "$STAGED"
|
||||||
|
"${PRIVILEGE[@]}" mv -f "$STAGED" "$INSTALL_DIR/$BINARY"
|
||||||
|
STAGED=""
|
||||||
|
|
||||||
|
# Regenerate aliases, including the configured command alias, as the calling user.
|
||||||
|
# An empty cache clears obsolete generated aliases. Never run the wizard here.
|
||||||
|
write_aliases() {
|
||||||
|
local kind=$1 path=$2 staged
|
||||||
|
"$INSTALL_DIR/$BINARY" alias --shell "$kind" > "$TMP/aliases-$kind" \
|
||||||
|
|| die "could not regenerate $kind aliases; profiles were not changed"
|
||||||
|
mkdir -p "$(dirname "$path")"
|
||||||
|
staged=$(mktemp "$(dirname "$path")/.netssh.XXXXXX")
|
||||||
|
cat "$TMP/aliases-$kind" > "$staged"
|
||||||
|
chmod 600 "$staged"
|
||||||
|
mv -f "$staged" "$path"
|
||||||
|
}
|
||||||
|
write_aliases bash "$CACHE_DIR/netssh/aliases.sh"
|
||||||
|
for kind in "${SHELLS[@]}"; do
|
||||||
|
if [[ "$kind" == fish ]]; then write_aliases fish "$CONFIG_DIR/fish/conf.d/netssh.fish"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
for ((index=0; index<${#PROFILE_PATHS[@]}; index++)); do
|
||||||
|
target=${PROFILE_TARGETS[$index]}
|
||||||
|
data=${PROFILE_DATA[$index]}
|
||||||
|
if [[ -e "$target" ]] && cmp -s "$target" "$data"; then continue; fi
|
||||||
|
mkdir -p "$(dirname "$target")"
|
||||||
|
if [[ -e "$target" ]]; then
|
||||||
|
backup=$(mktemp "$target.netssh-backup.XXXXXX")
|
||||||
|
cp -p "$target" "$backup"
|
||||||
|
info "backup : $backup"
|
||||||
|
fi
|
||||||
|
staged=$(mktemp "$(dirname "$target")/.netssh.XXXXXX")
|
||||||
|
if [[ -e "$target" ]]; then cp -p "$target" "$staged"; else chmod 644 "$staged"; fi
|
||||||
|
cat "$data" > "$staged"
|
||||||
|
mv -f "$staged" "$target"
|
||||||
|
info "updated : ${PROFILE_PATHS[$index]}"
|
||||||
|
done
|
||||||
|
for kind in "${SHELLS[@]}"; do
|
||||||
|
if ! "$INSTALL_DIR/$BINARY" completion install --shell "$kind"; then
|
||||||
|
info "warning: could not update $kind completion"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf '\nnetssh %s installed / updated at %s/%s\n' "$TAG" "$INSTALL_DIR" "$BINARY"
|
||||||
|
info "Open a new terminal to load the current hooks and aliases."
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
package netssh_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type installerFixture struct {
|
||||||
|
home, bin, assets string
|
||||||
|
env []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInstallerFixture(t *testing.T) installerFixture {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
f := installerFixture{home: filepath.Join(root, "home"), bin: filepath.Join(root, "tools"), assets: filepath.Join(root, "assets")}
|
||||||
|
for _, dir := range []string{f.home, f.bin, f.assets} {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeFixture(t, filepath.Join(f.bin, "curl"), `#!/bin/bash
|
||||||
|
set -eu
|
||||||
|
out=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in -o) out=$2; shift 2 ;; *) url=$1; shift ;; esac
|
||||||
|
done
|
||||||
|
case "$url" in
|
||||||
|
*/releases/latest) printf '{"tag_name": "v0.0.99"}\n'; exit ;;
|
||||||
|
*/checksums.txt) [[ "${CHECKSUM_MODE:-}" != missing ]] || exit 22; file="$FIXTURE_ASSETS/checksums.txt" ;;
|
||||||
|
*) file="$FIXTURE_ASSETS/netssh_linux_amd64" ;;
|
||||||
|
esac
|
||||||
|
if [[ -n "$out" ]]; then cp "$file" "$out"; else cat "$file"; fi
|
||||||
|
`, 0o755)
|
||||||
|
writeFixture(t, filepath.Join(f.bin, "uname"), "#!/bin/sh\ncase \"$1\" in -s) echo Linux;; -m) echo x86_64;; esac\n", 0o755)
|
||||||
|
// Any accidental runtime dependency on Python must fail the installation.
|
||||||
|
writeFixture(t, filepath.Join(f.bin, "python3"), "#!/bin/sh\nexit 99\n", 0o755)
|
||||||
|
binary := `#!/bin/bash
|
||||||
|
set -eu
|
||||||
|
case "$1" in
|
||||||
|
alias)
|
||||||
|
if [[ "$3" == fish ]]; then printf "alias j 'netssh'\n"; else printf "alias j='netssh'\n"; fi ;;
|
||||||
|
completion) exit 0 ;;
|
||||||
|
shell-init) exit 0 ;;
|
||||||
|
*) exit 1 ;;
|
||||||
|
esac
|
||||||
|
`
|
||||||
|
writeFixture(t, filepath.Join(f.assets, "netssh_linux_amd64"), binary, 0o755)
|
||||||
|
checksum := fmt.Sprintf("%x netssh_linux_amd64\n", sha256.Sum256([]byte(binary)))
|
||||||
|
writeFixture(t, filepath.Join(f.assets, "checksums.txt"), checksum, 0o644)
|
||||||
|
f.env = append(os.Environ(), "HOME="+f.home, "XDG_CONFIG_HOME="+filepath.Join(f.home, "config"), "XDG_CACHE_HOME="+filepath.Join(f.home, "cache"), "ZDOTDIR="+f.home, "SHELL=/bin/bash", "PATH="+f.bin+":"+os.Getenv("PATH"), "FIXTURE_ASSETS="+f.assets, "INSTALL_DIR="+filepath.Join(f.home, "local bin's"), "BASH_ENV=", "CHECKSUM_MODE=")
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFixture(t *testing.T, path, content string, mode os.FileMode) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f installerFixture) run(t *testing.T) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
// Piping stdin mirrors curl | bash, including EOF for any child commands.
|
||||||
|
script, err := os.ReadFile("install.sh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd := exec.Command("bash")
|
||||||
|
cmd.Env = f.env
|
||||||
|
cmd.Stdin = strings.NewReader(string(script))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerUpdatesAndConsolidatesProfilesWithoutPython(t *testing.T) {
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
original := "export KEEP=yes\n# netssh shell-init is documented here\necho 'netssh shell-init'\nnetssh shell-init\nnetssh shell-init # duplicate\nsource ~/.cache/netssh/aliases.sh 2>/dev/null\neval \"$(netssh alias)\"\nalias old='netssh old.example.com'\n"
|
||||||
|
target := filepath.Join(f.home, "dotfiles", "bashrc")
|
||||||
|
writeFixture(t, target, original, 0o600)
|
||||||
|
bashrc := filepath.Join(f.home, ".bashrc")
|
||||||
|
if err := os.Symlink(target, bashrc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFixture(t, filepath.Join(f.home, ".zshrc"), "export ZSH_KEEP=yes\nnetssh shell-init\n", 0o644)
|
||||||
|
writeFixture(t, filepath.Join(f.home, ".profile"), "export LOGIN_KEEP=yes\nnetssh shell-init\n", 0o644)
|
||||||
|
fish := filepath.Join(f.home, "config", "fish", "config.fish")
|
||||||
|
writeFixture(t, fish, "set KEEP yes\nnetssh alias --shell fish | source\n", 0o644)
|
||||||
|
installed := filepath.Join(f.home, "local bin's", "netssh")
|
||||||
|
writeFixture(t, installed, "old binary", 0o755)
|
||||||
|
if out, err := f.run(t); err != nil {
|
||||||
|
t.Fatalf("install: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
for _, path := range []string{bashrc, filepath.Join(f.home, ".zshrc"), fish} {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Count(string(data), "# >>> netssh >>>") != 1 {
|
||||||
|
t.Fatalf("bad managed blocks: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(bashrc)
|
||||||
|
for _, kept := range []string{"export KEEP=yes", "# netssh shell-init is documented here", "echo 'netssh shell-init'"} {
|
||||||
|
if !strings.Contains(string(data), kept) {
|
||||||
|
t.Fatalf("unrelated line lost: %s", kept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "alias old=") || strings.Contains(string(data), "eval ") {
|
||||||
|
t.Fatalf("legacy entries remain: %s", data)
|
||||||
|
}
|
||||||
|
if info, err := os.Lstat(bashrc); err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||||
|
t.Fatalf("symlink lost: %v", err)
|
||||||
|
}
|
||||||
|
if info, _ := os.Stat(target); info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatal("permissions changed")
|
||||||
|
}
|
||||||
|
backups, _ := filepath.Glob(target + ".netssh-backup.*")
|
||||||
|
if len(backups) != 1 {
|
||||||
|
t.Fatalf("backups: %v", backups)
|
||||||
|
}
|
||||||
|
backup, _ := os.ReadFile(backups[0])
|
||||||
|
if string(backup) != original {
|
||||||
|
t.Fatal("backup lost original content")
|
||||||
|
}
|
||||||
|
before := string(data)
|
||||||
|
if out, err := f.run(t); err != nil {
|
||||||
|
t.Fatalf("second install: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
data, _ = os.ReadFile(bashrc)
|
||||||
|
if string(data) != before {
|
||||||
|
t.Fatalf("not idempotent:\n%s", data)
|
||||||
|
}
|
||||||
|
backups, _ = filepath.Glob(target + ".netssh-backup.*")
|
||||||
|
if len(backups) != 1 {
|
||||||
|
t.Fatal("unchanged profile backed up again")
|
||||||
|
}
|
||||||
|
// Execute the generated Bash block to verify paths containing quotes and spaces.
|
||||||
|
cmd := exec.Command("bash", "-c", `. "$HOME/.bashrc"; alias j`)
|
||||||
|
cmd.Env = f.env
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil || !strings.Contains(string(out), "alias j='netssh'") {
|
||||||
|
t.Fatalf("source: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerRejectsUnverifiedDownloads(t *testing.T) {
|
||||||
|
for _, mode := range []string{"missing", "mismatch", "no-entry", "duplicate"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
f.env = append(f.env, "CHECKSUM_MODE="+mode)
|
||||||
|
sums := filepath.Join(f.assets, "checksums.txt")
|
||||||
|
switch mode {
|
||||||
|
case "mismatch":
|
||||||
|
writeFixture(t, sums, strings.Repeat("0", 64)+" netssh_linux_amd64\n", 0o644)
|
||||||
|
case "no-entry":
|
||||||
|
writeFixture(t, sums, strings.Repeat("0", 64)+" other_binary\n", 0o644)
|
||||||
|
case "duplicate":
|
||||||
|
data, _ := os.ReadFile(sums)
|
||||||
|
writeFixture(t, sums, string(data)+string(data), 0o644)
|
||||||
|
}
|
||||||
|
installed := filepath.Join(f.home, "local bin's", "netssh")
|
||||||
|
writeFixture(t, installed, "old binary", 0o755)
|
||||||
|
profile := filepath.Join(f.home, ".bashrc")
|
||||||
|
writeFixture(t, profile, "old profile\n", 0o600)
|
||||||
|
if out, err := f.run(t); err == nil {
|
||||||
|
t.Fatalf("accepted invalid checksum: %s", out)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(installed)
|
||||||
|
if string(data) != "old binary" {
|
||||||
|
t.Fatal("binary changed")
|
||||||
|
}
|
||||||
|
data, _ = os.ReadFile(profile)
|
||||||
|
if string(data) != "old profile\n" {
|
||||||
|
t.Fatal("profile changed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerRefusesBrokenManagedBlock(t *testing.T) {
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
profile := filepath.Join(f.home, ".bashrc")
|
||||||
|
original := "export KEEP=yes\n# >>> netssh >>>\nunfinished\n"
|
||||||
|
writeFixture(t, profile, original, 0o644)
|
||||||
|
if out, err := f.run(t); err == nil {
|
||||||
|
t.Fatalf("accepted broken block: %s", out)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(profile)
|
||||||
|
if string(data) != original {
|
||||||
|
t.Fatal("profile changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerUsesShasumAndFindsExistingBinary(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("shasum"); err != nil {
|
||||||
|
t.Skip("shasum not available")
|
||||||
|
}
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
// Hide sha256sum (and Python) as on a standard macOS installation.
|
||||||
|
for _, name := range []string{"shasum", "sed", "awk", "mktemp", "rm", "dirname", "readlink", "mkdir", "cat", "install", "mv", "cp", "chmod", "cmp", "perl"} {
|
||||||
|
tool, err := exec.LookPath(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(tool, filepath.Join(f.bin, name)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.env = append(f.env, "PATH="+f.bin, "INSTALL_DIR=")
|
||||||
|
installed := filepath.Join(f.bin, "netssh")
|
||||||
|
writeFixture(t, installed, "#!/bin/sh\nexit 1\n", 0o755)
|
||||||
|
if out, err := f.run(t); err != nil {
|
||||||
|
t.Fatalf("shasum update: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(installed)
|
||||||
|
expected, _ := os.ReadFile(filepath.Join(f.assets, "netssh_linux_amd64"))
|
||||||
|
if string(data) != string(expected) {
|
||||||
|
t.Fatal("existing installation was not replaced")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerRespectsZdotdir(t *testing.T) {
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
zshDir := filepath.Join(f.home, "custom-zsh")
|
||||||
|
f.env = append(f.env, "SHELL=/bin/zsh", "ZDOTDIR="+zshDir)
|
||||||
|
old := filepath.Join(f.home, ".zshrc")
|
||||||
|
writeFixture(t, old, "export KEEP=yes\nnetssh shell-init\n", 0o644)
|
||||||
|
if out, err := f.run(t); err != nil {
|
||||||
|
t.Fatalf("zsh setup: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(filepath.Join(zshDir, ".zshrc"))
|
||||||
|
if strings.Count(string(data), "# >>> netssh >>>") != 1 {
|
||||||
|
t.Fatalf("missing custom zsh setup: %s", data)
|
||||||
|
}
|
||||||
|
data, _ = os.ReadFile(old)
|
||||||
|
if string(data) != "export KEEP=yes\n" {
|
||||||
|
t.Fatalf("old zsh setup not cleaned: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerUsesMacOSCacheLayout(t *testing.T) {
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
writeFixture(t, filepath.Join(f.bin, "uname"), "#!/bin/sh\ncase \"$1\" in -s) echo Darwin;; -m) echo x86_64;; esac\n", 0o755)
|
||||||
|
data, _ := os.ReadFile(filepath.Join(f.assets, "netssh_linux_amd64"))
|
||||||
|
writeFixture(t, filepath.Join(f.assets, "checksums.txt"), fmt.Sprintf("%x netssh_darwin_amd64\n", sha256.Sum256(data)), 0o644)
|
||||||
|
f.env = append(f.env, "XDG_CACHE_HOME=")
|
||||||
|
if out, err := f.run(t); err != nil {
|
||||||
|
t.Fatalf("macOS setup: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(f.home, "Library", "Caches", "netssh", "aliases.sh")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerChecksDependenciesBeforeDownloading(t *testing.T) {
|
||||||
|
for _, missing := range [][]string{{"awk", "cmp"}, {"curl"}, {"sha256sum", "shasum"}} {
|
||||||
|
t.Run(strings.Join(missing, "+"), func(t *testing.T) {
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
// Build an isolated PATH containing every dependency except the selected tools.
|
||||||
|
for _, name := range []string{"curl", "uname", "sed", "awk", "mktemp", "rm", "dirname", "readlink", "mkdir", "cat", "install", "mv", "cp", "chmod", "cmp", "sha256sum"} {
|
||||||
|
skip := false
|
||||||
|
for _, absent := range missing {
|
||||||
|
if name == absent {
|
||||||
|
skip = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skip {
|
||||||
|
if err := os.Remove(filepath.Join(f.bin, name)); err != nil && !os.IsNotExist(err) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(f.bin, name)); err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path, err := exec.LookPath(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(path, filepath.Join(f.bin, name)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
marker := filepath.Join(f.assets, "curl-was-called")
|
||||||
|
// Replacing curl with a marker makes any premature network operation observable.
|
||||||
|
if missing[0] != "curl" {
|
||||||
|
writeFixture(t, filepath.Join(f.bin, "curl"), "#!/bin/bash\nprintf called > \"$FIXTURE_ASSETS/curl-was-called\"\nexit 99\n", 0o755)
|
||||||
|
}
|
||||||
|
f.env = append(f.env, "PATH="+f.bin)
|
||||||
|
installed := filepath.Join(f.home, "local bin's", "netssh")
|
||||||
|
writeFixture(t, installed, "old binary", 0o755)
|
||||||
|
out, err := f.run(t)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("missing dependencies accepted: %s", out)
|
||||||
|
}
|
||||||
|
for _, name := range missing {
|
||||||
|
if !strings.Contains(out, name) {
|
||||||
|
t.Errorf("missing diagnostic for %s: %s", name, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("curl ran before dependency checks completed")
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(installed)
|
||||||
|
if string(data) != "old binary" {
|
||||||
|
t.Fatal("binary changed")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(f.home, ".bashrc")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("profile created")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerChecksSudoBeforeDownloadingWhenNeeded(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("root can write directories regardless of mode bits")
|
||||||
|
}
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
for _, name := range []string{"sed", "awk", "mktemp", "rm", "dirname", "readlink", "mkdir", "cat", "install", "mv", "cp", "chmod", "cmp", "sha256sum"} {
|
||||||
|
path, err := exec.LookPath(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(path, filepath.Join(f.bin, name)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
locked := filepath.Join(f.home, "locked")
|
||||||
|
if err := os.Mkdir(locked, 0o555); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.Chmod(locked, 0o755) })
|
||||||
|
writeFixture(t, filepath.Join(f.bin, "curl"), "#!/bin/bash\nprintf called > \"$FIXTURE_ASSETS/curl-was-called\"\nexit 99\n", 0o755)
|
||||||
|
f.env = append(f.env, "PATH="+f.bin, "INSTALL_DIR="+filepath.Join(locked, "bin"))
|
||||||
|
out, err := f.run(t)
|
||||||
|
if err == nil || !strings.Contains(out, "sudo") {
|
||||||
|
t.Fatalf("missing sudo diagnostic: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(f.assets, "curl-was-called")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("download ran before sudo check")
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+5
-1
@@ -2,6 +2,7 @@ package cache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -116,12 +117,15 @@ func (c *Cache) Save() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(c.path, data, 0o644)
|
return storage.WriteFile(c.path, data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) Upsert(e Entry) {
|
func (c *Cache) Upsert(e Entry) {
|
||||||
e.CachedAt = time.Now()
|
e.CachedAt = time.Now()
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
if old, ok := c.entries[e.Name]; ok && e.LastUsed.IsZero() {
|
||||||
|
e.LastUsed = old.LastUsed
|
||||||
|
}
|
||||||
c.entries[e.Name] = e
|
c.entries[e.Name] = e
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+12
@@ -471,3 +471,15 @@ func tempFile(t *testing.T, content []byte) string {
|
|||||||
f.Close()
|
f.Close()
|
||||||
return f.Name()
|
return f.Name()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpsertPreservesUsageHistory(t *testing.T) {
|
||||||
|
c := New("", 3600)
|
||||||
|
c.Upsert(Entry{Name: "web01", IP: "10.0.0.1"})
|
||||||
|
c.MarkUsed("web01")
|
||||||
|
before, _ := c.Get("web01")
|
||||||
|
c.Upsert(Entry{Name: "web01", IP: "10.0.0.2"})
|
||||||
|
after, _ := c.Get("web01")
|
||||||
|
if after.LastUsed != before.LastUsed || after.IP != "10.0.0.2" {
|
||||||
|
t.Fatalf("history lost: %+v", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
Shell ShellConfig `mapstructure:"shell"`
|
||||||
NetBox NetBoxConfig `mapstructure:"netbox"`
|
NetBox NetBoxConfig `mapstructure:"netbox"`
|
||||||
Resolver ResolverConfig `mapstructure:"resolver"`
|
Resolver ResolverConfig `mapstructure:"resolver"`
|
||||||
Cache CacheConfig `mapstructure:"cache"`
|
Cache CacheConfig `mapstructure:"cache"`
|
||||||
@@ -17,6 +19,10 @@ type Config struct {
|
|||||||
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ShellConfig struct {
|
||||||
|
Alias string `mapstructure:"alias"`
|
||||||
|
}
|
||||||
|
|
||||||
type NetBoxConfig struct {
|
type NetBoxConfig struct {
|
||||||
URL string `mapstructure:"url"`
|
URL string `mapstructure:"url"`
|
||||||
Token string `mapstructure:"token"`
|
Token string `mapstructure:"token"`
|
||||||
@@ -69,7 +75,13 @@ func Load() (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
v.SetEnvPrefix("NETSSH")
|
v.SetEnvPrefix("NETSSH")
|
||||||
|
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||||
v.AutomaticEnv()
|
v.AutomaticEnv()
|
||||||
|
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias"} {
|
||||||
|
if err := v.BindEnv(key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := v.ReadInConfig(); err != nil {
|
if err := v.ReadInConfig(); err != nil {
|
||||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||||
@@ -82,6 +94,10 @@ func Load() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("parsing config: %w", err)
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.NetBox.TokenVersion == 0 && cfg.NetBox.Token != "" {
|
if cfg.NetBox.TokenVersion == 0 && cfg.NetBox.Token != "" {
|
||||||
if strings.HasPrefix(cfg.NetBox.Token, "nbt_") {
|
if strings.HasPrefix(cfg.NetBox.Token, "nbt_") {
|
||||||
cfg.NetBox.TokenVersion = 2
|
cfg.NetBox.TokenVersion = 2
|
||||||
@@ -100,3 +116,18 @@ func Load() (*Config, error) {
|
|||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateCommandAlias restricts aliases to portable shell identifiers.
|
||||||
|
func ValidateCommandAlias(name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`).MatchString(name) {
|
||||||
|
return fmt.Errorf("shell.alias must be a shell identifier (e.g. j)")
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case "netssh", "alias", "unalias", "source", "eval", "exec", "command", "builtin", "if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done", "case", "esac", "in", "function", "select", "time", "coproc", "end", "begin", "switch", "set", "export", "return", "break", "continue", "not", "and", "or":
|
||||||
|
return fmt.Errorf("shell.alias %q is reserved; choose another name", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -143,3 +143,37 @@ func TestPath_ReturnsNonEmpty(t *testing.T) {
|
|||||||
t.Errorf("Path() base: got %q, want netssh.yaml", filepath.Base(p))
|
t.Errorf("Path() base: got %q, want netssh.yaml", filepath.Base(p))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnvironmentOverridesWithoutConfigKeys(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
t.Setenv("NETSSH_NETBOX_URL", "https://env.example")
|
||||||
|
t.Setenv("NETSSH_NETBOX_TOKEN", "nbt_env")
|
||||||
|
t.Setenv("NETSSH_SSH_DEFAULT_PORT", "2222")
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.NetBox.URL != "https://env.example" || cfg.NetBox.Token != "nbt_env" || cfg.NetBox.TokenVersion != 2 || cfg.SSH.DefaultPort != 2222 {
|
||||||
|
t.Fatalf("env ignored: %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandAliasEnvironmentAndValidation(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
t.Setenv("NETSSH_SHELL_ALIAS", "j")
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil || cfg.Shell.Alias != "j" {
|
||||||
|
t.Fatalf("alias env: %+v, %v", cfg, err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"netssh", "if", "alias", "j;touch /tmp/bad", "a b", "1bad"} {
|
||||||
|
t.Setenv("NETSSH_SHELL_ALIAS", name)
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Errorf("unsafe alias accepted: %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{"", "j", "jump_server", "goSSH"} {
|
||||||
|
if err := ValidateCommandAlias(name); err != nil {
|
||||||
|
t.Errorf("valid alias rejected: %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package hook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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/shortcuts"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 := os.Getenv("XDG_CACHE_HOME")
|
||||||
|
if cacheDir == "" {
|
||||||
|
var err error
|
||||||
|
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 {
|
||||||
|
return filepath.Join(FishConfigDir(), "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 {
|
||||||
|
b.WriteString(e.Definition("bash") + "\n")
|
||||||
|
}
|
||||||
|
return storage.WriteFile(p, []byte(b.String()), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFishAliasFile(entries []AliasEntry) {
|
||||||
|
// Only write if fish is configured on this system.
|
||||||
|
if _, err := os.Stat(FishConfigDir()); 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 {
|
||||||
|
b.WriteString(e.Definition("fish") + "\n")
|
||||||
|
}
|
||||||
|
storage.WriteFile(p, []byte(b.String()), 0o600) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty Host represents an alias for the netssh command itself.
|
||||||
|
func (e AliasEntry) Definition(shell string) string {
|
||||||
|
command := "netssh"
|
||||||
|
if e.Host != "" {
|
||||||
|
host := e.Host
|
||||||
|
if !regexp.MustCompile(`^[a-zA-Z0-9_.:-]+$`).MatchString(host) {
|
||||||
|
host = shellQuote(host, shell)
|
||||||
|
}
|
||||||
|
command += " " + host
|
||||||
|
}
|
||||||
|
if shell == "fish" {
|
||||||
|
return fmt.Sprintf("alias %s %s", e.Name, shellQuote(command, shell))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("alias %s=%s", e.Name, shellQuote(command, shell))
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(value, shell string) string {
|
||||||
|
if shell == "fish" {
|
||||||
|
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||||
|
return "'" + strings.ReplaceAll(value, "'", `\'`) + "'"
|
||||||
|
}
|
||||||
|
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAliasEntries gives the configured command alias priority over host aliases.
|
||||||
|
func BuildAliasEntries(entries []cache.Entry, cfg config.Config) []AliasEntry {
|
||||||
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
|
||||||
|
seen := map[string]bool{"netssh": true}
|
||||||
|
var out []AliasEntry
|
||||||
|
if cfg.Shell.Alias != "" && config.ValidateCommandAlias(cfg.Shell.Alias) == nil {
|
||||||
|
seen[cfg.Shell.Alias] = true
|
||||||
|
out = append(out, AliasEntry{Name: cfg.Shell.Alias})
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
name := shortcuts.AliasName(e.Name, cfg.Shortcuts)
|
||||||
|
if seen[name] || name == "" || !regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_-]*$`).MatchString(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
out = append(out, AliasEntry{Name: name, Host: e.Name})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// FishConfigDir follows fish's XDG layout on both Linux and macOS.
|
||||||
|
func FishConfigDir() string {
|
||||||
|
dir := os.Getenv("XDG_CONFIG_HOME")
|
||||||
|
if dir == "" {
|
||||||
|
dir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "fish")
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package hook_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandAliasGenerationAndCollision(t *testing.T) {
|
||||||
|
cfg := config.Config{Shell: config.ShellConfig{Alias: "j"}}
|
||||||
|
entries := hook.BuildAliasEntries([]cache.Entry{{Name: "j"}, {Name: "netssh"}, {Name: "web-01"}}, cfg)
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("entries: %+v", entries)
|
||||||
|
}
|
||||||
|
if got := entries[0].Definition("bash"); got != "alias j='netssh'" {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
if got := entries[0].Definition("fish"); got != "alias j 'netssh'" {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
if got := entries[1].Definition("bash"); got != "alias web-01='netssh web-01'" {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
-22
@@ -4,14 +4,24 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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"
|
Marker = "netssh shell-init"
|
||||||
|
BlockStart = "# >>> netssh >>>"
|
||||||
|
BlockEnd = "# <<< netssh <<<"
|
||||||
// Line is the full line written into the shell profile.
|
// Line is the full line written into the shell profile.
|
||||||
Line = "netssh shell-init # netssh cache auto-refresh"
|
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.
|
// ProfilePath returns the canonical shell profile path for the given shell name.
|
||||||
@@ -20,13 +30,13 @@ func ProfilePath(shell string) (string, error) {
|
|||||||
case "bash":
|
case "bash":
|
||||||
return filepath.Join(os.Getenv("HOME"), ".bashrc"), nil
|
return filepath.Join(os.Getenv("HOME"), ".bashrc"), nil
|
||||||
case "zsh":
|
case "zsh":
|
||||||
return filepath.Join(os.Getenv("HOME"), ".zshrc"), nil
|
dir := os.Getenv("ZDOTDIR")
|
||||||
case "fish":
|
if dir == "" {
|
||||||
configDir, err := os.UserConfigDir()
|
dir = os.Getenv("HOME")
|
||||||
if err != nil {
|
|
||||||
configDir = filepath.Join(os.Getenv("HOME"), ".config")
|
|
||||||
}
|
}
|
||||||
return filepath.Join(configDir, "fish", "config.fish"), nil
|
return filepath.Join(dir, ".zshrc"), nil
|
||||||
|
case "fish":
|
||||||
|
return filepath.Join(FishConfigDir(), "config.fish"), nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unsupported shell %q — supported: bash, zsh, fish", shell)
|
return "", fmt.Errorf("unsupported shell %q — supported: bash, zsh, fish", shell)
|
||||||
}
|
}
|
||||||
@@ -44,7 +54,12 @@ func IsInstalled(profilePath string) bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return strings.Contains(string(data), Marker)
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.TrimSpace(line) == BlockStart || hookLinePattern.MatchString(strings.TrimSpace(line)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install appends the hook line to the profile if it is not already present.
|
// Install appends the hook line to the profile if it is not already present.
|
||||||
@@ -68,9 +83,35 @@ func Install(profilePath string) (installed bool, err error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uninstall removes the hook line from the profile.
|
// InstallAliasesSource appends the aliases source line to a bash/zsh profile.
|
||||||
// Returns (true, nil) when removed, (false, nil) when the hook was not found.
|
// This is a no-op for fish (which uses conf.d auto-sourcing instead).
|
||||||
// Returns (false, nil) — not an error — when the file does not exist.
|
// 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)
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.TrimSpace(line) == BlockStart || aliasesLinePattern.MatchString(strings.TrimSpace(line)) {
|
||||||
|
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", "source "+shellQuote(AliasesPath(), "bash")+" 2>/dev/null # netssh aliases"); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var hookLinePattern = regexp.MustCompile(`^(?:[^\s]*/)?netssh\s+shell-init\s*(?:#.*)?$`)
|
||||||
|
var aliasesLinePattern = regexp.MustCompile(`^(?:source|\.)\s+["']?[^;|&]*netssh/aliases\.sh["']?(?:\s+2>/dev/null)?\s*(?:#.*)?$`)
|
||||||
|
|
||||||
|
// Uninstall removes legacy hook lines and complete installer-managed blocks.
|
||||||
|
// Missing profiles are a no-op; malformed blocks leave the file unchanged.
|
||||||
func Uninstall(profilePath string) (removed bool, err error) {
|
func Uninstall(profilePath string) (removed bool, err error) {
|
||||||
data, err := os.ReadFile(profilePath)
|
data, err := os.ReadFile(profilePath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -79,22 +120,50 @@ func Uninstall(profilePath string) (removed bool, err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("reading profile: %w", err)
|
return false, fmt.Errorf("reading profile: %w", err)
|
||||||
}
|
}
|
||||||
content := string(data)
|
|
||||||
if !strings.Contains(content, Marker) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var kept []string
|
var kept []string
|
||||||
for _, line := range strings.Split(content, "\n") {
|
inside := false
|
||||||
if strings.Contains(line, Marker) {
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
value := strings.TrimSpace(line)
|
||||||
|
switch value {
|
||||||
|
case BlockStart:
|
||||||
|
if inside {
|
||||||
|
return false, fmt.Errorf("nested netssh block in %s", profilePath)
|
||||||
|
}
|
||||||
|
inside = true
|
||||||
|
removed = true
|
||||||
|
continue
|
||||||
|
case BlockEnd:
|
||||||
|
if !inside {
|
||||||
|
return false, fmt.Errorf("unmatched netssh block in %s", profilePath)
|
||||||
|
}
|
||||||
|
inside = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inside {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hookLinePattern.MatchString(value) || aliasesLinePattern.MatchString(value) {
|
||||||
|
removed = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
kept = append(kept, line)
|
kept = append(kept, line)
|
||||||
}
|
}
|
||||||
// Collapse triple blank lines that the removal may leave.
|
if inside {
|
||||||
|
return false, fmt.Errorf("unclosed netssh block in %s", profilePath)
|
||||||
|
}
|
||||||
|
if !removed {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
cleaned := strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n")
|
cleaned := strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n")
|
||||||
|
target, err := filepath.EvalSymlinks(profilePath)
|
||||||
if err := os.WriteFile(profilePath, []byte(cleaned), 0o644); err != nil {
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
info, err := os.Stat(target)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := storage.WriteFile(target, []byte(cleaned), info.Mode().Perm()); err != nil {
|
||||||
return false, fmt.Errorf("writing profile: %w", err)
|
return false, fmt.Errorf("writing profile: %w", err)
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
|
|||||||
@@ -261,3 +261,30 @@ func writeProfile(t *testing.T, content string) string {
|
|||||||
}
|
}
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
t.Fatalf("duplicate install: %v, %v", installed, err)
|
||||||
|
}
|
||||||
|
if installed, err := hook.InstallAliasesSource(profile); err != nil || installed {
|
||||||
|
t.Fatalf("duplicate source: %v, %v", installed, err)
|
||||||
|
}
|
||||||
|
if removed, err := hook.Uninstall(profile); err != nil || !removed {
|
||||||
|
t.Fatalf("uninstall: %v, %v", removed, err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(profile)
|
||||||
|
if string(data) != original {
|
||||||
|
t.Fatalf("unrelated content changed: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfilePathRespectsZdotdir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("ZDOTDIR", dir)
|
||||||
|
path, err := hook.ProfilePath("zsh")
|
||||||
|
if err != nil || path != filepath.Join(dir, ".zshrc") {
|
||||||
|
t.Fatalf("zsh path: %s, %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
-17
@@ -1,13 +1,17 @@
|
|||||||
package netbox
|
package netbox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -27,13 +31,16 @@ func NewClient(baseURL, token string, tokenVersion int) *Client {
|
|||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
token: token,
|
token: token,
|
||||||
tokenVersion: tokenVersion,
|
tokenVersion: tokenVersion,
|
||||||
httpClient: &http.Client{},
|
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search queries up to 50 devices and VMs in parallel and merges the results.
|
// Search queries up to 50 devices and VMs in parallel and merges the results.
|
||||||
// Use SearchOptions to restrict by kind or tag.
|
// Use SearchOptions to restrict by kind or tag.
|
||||||
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
||||||
|
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||||
|
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
||||||
|
}
|
||||||
var (
|
var (
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
results []HostEntry
|
results []HostEntry
|
||||||
@@ -88,16 +95,17 @@ func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) (
|
|||||||
// SearchAll paginates through all matching devices and VMs, fetching every page.
|
// SearchAll paginates through all matching devices and VMs, fetching every page.
|
||||||
// Intended for cache refresh; use Search for interactive queries.
|
// Intended for cache refresh; use Search for interactive queries.
|
||||||
func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
||||||
|
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||||
|
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
||||||
|
}
|
||||||
var (
|
var (
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
results []HostEntry
|
results []HostEntry
|
||||||
errs []error
|
errs []error
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
started int
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if opts.Kind != "vm" {
|
if opts.Kind != "vm" {
|
||||||
started++
|
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
@@ -113,7 +121,6 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.Kind != "device" {
|
if opts.Kind != "device" {
|
||||||
started++
|
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
@@ -130,11 +137,8 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
|
|||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if len(errs) == started {
|
if len(errs) > 0 {
|
||||||
if started == 1 {
|
return nil, errors.Join(errs...)
|
||||||
return nil, errs[0]
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1])
|
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
@@ -265,16 +269,31 @@ func TokenVersion(token string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) get(ctx context.Context, apiURL string, out any) error {
|
func (c *Client) get(ctx context.Context, apiURL string, out any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
_, err := c.request(ctx, http.MethodGet, apiURL, nil, "", out)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) request(ctx context.Context, method, apiURL string, body io.Reader, etag string, out any) (string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, apiURL, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating request: %w", err)
|
return "", fmt.Errorf("creating request: %w", err)
|
||||||
|
}
|
||||||
|
scheme := "Token "
|
||||||
|
if c.tokenVersion == 2 {
|
||||||
|
scheme = "Bearer "
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", scheme+c.token)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if etag != "" {
|
||||||
|
req.Header.Set("If-Match", etag)
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Token "+c.token)
|
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("request to %s: %w", apiURL, err)
|
return "", fmt.Errorf("request to %s: %w", apiURL, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
@@ -283,17 +302,17 @@ func (c *Client) get(ctx context.Context, apiURL string, out any) error {
|
|||||||
if c.tokenVersion == 1 {
|
if c.tokenVersion == 1 {
|
||||||
hint += " — legacy v1 token detected, consider upgrading to a v2 token (starts with nbt_)"
|
hint += " — legacy v1 token detected, consider upgrading to a v2 token (starts with nbt_)"
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s: %s", apiURL, hint)
|
return "", fmt.Errorf("%s: %s", apiURL, hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL)
|
return "", fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||||
return fmt.Errorf("decoding response: %w", err)
|
return "", fmt.Errorf("decoding response: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return resp.Header.Get("ETag"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deviceToEntry(d netboxDevice) HostEntry {
|
func deviceToEntry(d netboxDevice) HostEntry {
|
||||||
@@ -331,3 +350,66 @@ func stripPrefix(cidr string) string {
|
|||||||
}
|
}
|
||||||
return cidr
|
return cidr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindHost refuses ambiguous names so writes cannot target an arbitrary server.
|
||||||
|
func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, error) {
|
||||||
|
if kind != "" && kind != "device" && kind != "vm" {
|
||||||
|
return HostEntry{}, fmt.Errorf("invalid kind %q", kind)
|
||||||
|
}
|
||||||
|
entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind})
|
||||||
|
if err != nil {
|
||||||
|
return HostEntry{}, err
|
||||||
|
}
|
||||||
|
var matches []HostEntry
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.EqualFold(e.Name, name) {
|
||||||
|
matches = append(matches, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(matches) != 1 {
|
||||||
|
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use --kind or a unique hostname", name, len(matches))
|
||||||
|
}
|
||||||
|
return matches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) hostURL(host HostEntry) (string, error) {
|
||||||
|
var path string
|
||||||
|
switch host.Kind {
|
||||||
|
case "device":
|
||||||
|
path = "dcim/devices"
|
||||||
|
case "vm":
|
||||||
|
path = "virtualization/virtual-machines"
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("invalid host kind %q", host.Kind)
|
||||||
|
}
|
||||||
|
if host.ID <= 0 {
|
||||||
|
return "", fmt.Errorf("invalid host ID")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s/api/%s/%d/", c.baseURL, path, host.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, string, error) {
|
||||||
|
u, err := c.hostURL(host)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Comments string `json:"comments"`
|
||||||
|
}
|
||||||
|
etag, err := c.request(ctx, http.MethodGet, u, nil, "", &out)
|
||||||
|
return out.Comments, etag, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetComments(ctx context.Context, host HostEntry, comments, etag string) error {
|
||||||
|
u, err := c.hostURL(host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(map[string]string{"comments": comments})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var out json.RawMessage
|
||||||
|
_, err = c.request(ctx, http.MethodPatch, u, bytes.NewReader(data), etag, &out)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -318,21 +318,26 @@ func Test403_V2Token_NoV1Hint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGet_SendsAuthorizationHeader(t *testing.T) {
|
func TestGet_SendsAuthorizationHeader(t *testing.T) {
|
||||||
var gotAuth string
|
for _, token := range []string{"nbt_mytoken", "legacytoken"} {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
t.Run(token, func(t *testing.T) {
|
||||||
gotAuth = r.Header.Get("Authorization")
|
want := "Token " + token
|
||||||
w.Header().Set("Content-Type", "application/json")
|
if strings.HasPrefix(token, "nbt_") {
|
||||||
b, _ := json.Marshal(deviceListResponse())
|
want = "Bearer " + token
|
||||||
w.Write(b)
|
}
|
||||||
}))
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
defer srv.Close()
|
if got := r.Header.Get("Authorization"); got != want {
|
||||||
|
t.Errorf("Authorization: got %q, want %q", got, want)
|
||||||
c := NewClient(srv.URL, "nbt_mytoken", 2)
|
}
|
||||||
c.Search(context.Background(), "", SearchOptions{}) //nolint:errcheck
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
b, _ := json.Marshal(deviceListResponse())
|
||||||
want := "Token nbt_mytoken"
|
w.Write(b)
|
||||||
if gotAuth != want {
|
}))
|
||||||
t.Errorf("Authorization header: got %q, want %q", gotAuth, want)
|
defer srv.Close()
|
||||||
|
c := NewClient(srv.URL, token, 0)
|
||||||
|
if _, err := c.Search(context.Background(), "", SearchOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,3 +439,63 @@ func TestStripPrefix(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCommentsPreserveFieldAndSendConflictHeader(t *testing.T) {
|
||||||
|
for _, kind := range []string{"device", "vm"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer nbt_test" {
|
||||||
|
t.Error("wrong authentication")
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodPatch {
|
||||||
|
if r.Header.Get("If-Match") != `W/"123"` {
|
||||||
|
t.Error("missing conflict protection")
|
||||||
|
}
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if len(payload) != 1 || payload["comments"] != "new\nnotes" {
|
||||||
|
t.Errorf("unexpected patch: %v", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("ETag", `W/"123"`)
|
||||||
|
w.Write([]byte(`{"comments":"existing notes"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := NewClient(srv.URL, "nbt_test", 0)
|
||||||
|
host := HostEntry{ID: 1, Kind: kind}
|
||||||
|
text, etag, err := c.GetComments(context.Background(), host)
|
||||||
|
if err != nil || text != "existing notes" {
|
||||||
|
t.Fatalf("read: %q, %v", text, err)
|
||||||
|
}
|
||||||
|
if err := c.SetComments(context.Background(), host, "new\nnotes", etag); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFullRefreshRejectsPartialFailure(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.Contains(r.URL.Path, "virtualization") {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write([]byte(`{"count":0,"results":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := NewClient(srv.URL, "token", 0)
|
||||||
|
if _, err := c.SearchAll(context.Background(), "", SearchOptions{}); err == nil {
|
||||||
|
t.Fatal("partial refresh reported as successful")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentConflictIsReported(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusPreconditionFailed) }))
|
||||||
|
defer srv.Close()
|
||||||
|
c := NewClient(srv.URL, "token", 0)
|
||||||
|
if err := c.SetComments(context.Background(), HostEntry{ID: 1, Kind: "device"}, "draft", "old"); err == nil {
|
||||||
|
t.Fatal("conflict ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Package notes stores private server notes independently of the host cache.
|
||||||
|
package notes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Note struct {
|
||||||
|
Text, ETag string
|
||||||
|
Host netbox.HostEntry
|
||||||
|
}
|
||||||
|
type Service struct {
|
||||||
|
Dir, Namespace string
|
||||||
|
Client *netbox.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(namespace string, client *netbox.Client) *Service {
|
||||||
|
dir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
dir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||||
|
}
|
||||||
|
return &Service{Dir: filepath.Join(dir, "netssh", "notes"), Namespace: strings.TrimRight(namespace, "/"), Client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) path(host netbox.HostEntry) string {
|
||||||
|
key := fmt.Sprintf("%s\x00%s\x00%s", s.Namespace, host.Kind, strings.ToLower(host.Name))
|
||||||
|
return filepath.Join(s.Dir, fmt.Sprintf("%x.txt", sha256.Sum256([]byte(key))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Read(ctx context.Context, host netbox.HostEntry, remote bool) (Note, error) {
|
||||||
|
note := Note{Host: host}
|
||||||
|
if remote {
|
||||||
|
if s.Client == nil {
|
||||||
|
return note, fmt.Errorf("NetBox is not configured")
|
||||||
|
}
|
||||||
|
if host.ID == 0 {
|
||||||
|
resolved, err := s.Client.FindHost(ctx, host.Name, host.Kind)
|
||||||
|
if err != nil {
|
||||||
|
return note, err
|
||||||
|
}
|
||||||
|
note.Host = resolved
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
note.Text, note.ETag, err = s.Client.GetComments(ctx, note.Host)
|
||||||
|
return note, err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(s.path(host))
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return note, nil
|
||||||
|
}
|
||||||
|
note.Text = string(data)
|
||||||
|
return note, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Write(ctx context.Context, note Note, remote bool) error {
|
||||||
|
if remote {
|
||||||
|
if s.Client == nil {
|
||||||
|
return fmt.Errorf("NetBox is not configured")
|
||||||
|
}
|
||||||
|
return s.Client.SetComments(ctx, note.Host, note.Text, note.ETag)
|
||||||
|
}
|
||||||
|
return storage.WriteFile(s.path(note.Host), []byte(note.Text), 0o600)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package notes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLocalNotesPersistAndStayIsolated(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := &Service{Dir: t.TempDir(), Namespace: "https://netbox.example"}
|
||||||
|
host := netbox.HostEntry{Name: "web01", Kind: "device"}
|
||||||
|
n := Note{Host: host, Text: "Maintenance\nReady 🔧"}
|
||||||
|
if err := s.Write(ctx, n, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reloaded := &Service{Dir: s.Dir, Namespace: s.Namespace}
|
||||||
|
got, err := reloaded.Read(ctx, host, false)
|
||||||
|
if err != nil || got.Text != n.Text {
|
||||||
|
t.Fatalf("round trip: %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(s.path(host))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("permissions: %v", info.Mode())
|
||||||
|
}
|
||||||
|
host.Kind = "vm"
|
||||||
|
got, err = s.Read(ctx, host, false)
|
||||||
|
if err != nil || got.Text != "" {
|
||||||
|
t.Fatalf("kind isolation: %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
host.Kind = "device"
|
||||||
|
reloaded.Namespace = "https://other.example"
|
||||||
|
got, err = reloaded.Read(ctx, host, false)
|
||||||
|
if err != nil || got.Text != "" {
|
||||||
|
t.Fatalf("instance isolation: %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnreadableNoteIsNotTreatedAsEmpty(t *testing.T) {
|
||||||
|
s := &Service{Dir: t.TempDir()}
|
||||||
|
host := netbox.HostEntry{Name: "web01", Kind: "vm"}
|
||||||
|
if err := os.Mkdir(s.path(host), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.Read(context.Background(), host, false); err == nil {
|
||||||
|
t.Fatal("expected read error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,11 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
|
|
||||||
|
"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/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/netbox"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
|
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
|
||||||
@@ -30,6 +33,7 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
|
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
|
||||||
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
|
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
|
||||||
stripHyphens := cfg.Shortcuts.StripHyphens
|
stripHyphens := cfg.Shortcuts.StripHyphens
|
||||||
|
commandAlias := cfg.Shell.Alias
|
||||||
|
|
||||||
if strategiesRaw == "" {
|
if strategiesRaw == "" {
|
||||||
strategiesRaw = "primary_ip"
|
strategiesRaw = "primary_ip"
|
||||||
@@ -122,6 +126,12 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
Title("Strip hyphens").
|
Title("Strip hyphens").
|
||||||
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
|
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
|
||||||
Value(&stripHyphens),
|
Value(&stripHyphens),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Command alias").
|
||||||
|
Description("Optional alias for netssh itself, e.g. j. Leave empty to disable.").
|
||||||
|
Placeholder("j").
|
||||||
|
Value(&commandAlias).
|
||||||
|
Validate(func(s string) error { return config.ValidateCommandAlias(strings.TrimSpace(s)) }),
|
||||||
).Title("Shortcuts"),
|
).Title("Shortcuts"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -136,7 +146,7 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
strategies := parseStrategies(strategiesRaw)
|
strategies := parseStrategies(strategiesRaw)
|
||||||
tokenVersion := netbox.TokenVersion(token)
|
tokenVersion := netbox.TokenVersion(token)
|
||||||
if tokenVersion == 1 {
|
if tokenVersion == 1 {
|
||||||
fmt.Fprintln(os.Stderr, "\nHinweis: Du verwendest einen Legacy-Token (v1). Erstelle in NetBox einen v2-Token (beginnt mit nbt_) für bessere Kompatibilität.")
|
fmt.Fprintln(os.Stderr, "\nNote: You are using a legacy token (v1). Create a v2 token in NetBox (starting with nbt_) for better compatibility.")
|
||||||
fmt.Fprintln(os.Stderr, " NetBox → Admin → API Tokens → Add Token")
|
fmt.Fprintln(os.Stderr, " NetBox → Admin → API Tokens → Add Token")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +175,7 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := config.Config{
|
out := config.Config{
|
||||||
|
Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)},
|
||||||
NetBox: config.NetBoxConfig{
|
NetBox: config.NetBoxConfig{
|
||||||
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
||||||
Token: strings.TrimSpace(token),
|
Token: strings.TrimSpace(token),
|
||||||
@@ -180,7 +191,8 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
InterfaceName: strings.TrimSpace(interfaceName),
|
InterfaceName: strings.TrimSpace(interfaceName),
|
||||||
},
|
},
|
||||||
Cache: config.CacheConfig{
|
Cache: config.CacheConfig{
|
||||||
TTL: ttl,
|
TTL: ttl,
|
||||||
|
Path: cfg.Cache.Path,
|
||||||
},
|
},
|
||||||
Shortcuts: config.ShortcutsConfig{
|
Shortcuts: config.ShortcutsConfig{
|
||||||
Domains: domainList,
|
Domains: domainList,
|
||||||
@@ -188,7 +200,38 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return save(out)
|
if err := save(out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
loaded, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c := cache.New(loaded.Cache.Path, loaded.Cache.TTL)
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
return fmt.Errorf("updating aliases: %w", err)
|
||||||
|
}
|
||||||
|
if err := hook.WriteAliasFiles(hook.BuildAliasEntries(c.All(), *loaded)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if loaded.Shell.Alias != "" {
|
||||||
|
shell := filepath.Base(os.Getenv("SHELL"))
|
||||||
|
profile, err := hook.ProfilePath(shell)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Run 'netssh hook install --shell bash|zsh|fish' to activate your alias.\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := hook.Install(profile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if shell != "fish" {
|
||||||
|
if _, err := hook.InstallAliasesSource(profile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, hook.ReloadNote(profile))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var knownStrategies = map[string]bool{
|
var knownStrategies = map[string]bool{
|
||||||
@@ -220,6 +263,9 @@ func parseStrategies(s string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func save(cfg config.Config) error {
|
func save(cfg config.Config) error {
|
||||||
|
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
path := config.Path()
|
path := config.Path()
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
return fmt.Errorf("creating config dir: %w", err)
|
return fmt.Errorf("creating config dir: %w", err)
|
||||||
@@ -248,6 +294,9 @@ func save(cfg config.Config) error {
|
|||||||
|
|
||||||
b.WriteString("\ncache:\n")
|
b.WriteString("\ncache:\n")
|
||||||
fmt.Fprintf(&b, " ttl: %d\n", cfg.Cache.TTL)
|
fmt.Fprintf(&b, " ttl: %d\n", cfg.Cache.TTL)
|
||||||
|
if cfg.Cache.Path != "" {
|
||||||
|
fmt.Fprintf(&b, " path: %q\n", cfg.Cache.Path)
|
||||||
|
}
|
||||||
|
|
||||||
b.WriteString("\nssh:\n")
|
b.WriteString("\nssh:\n")
|
||||||
if cfg.SSH.DefaultUser != "" {
|
if cfg.SSH.DefaultUser != "" {
|
||||||
@@ -270,7 +319,11 @@ func save(cfg config.Config) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
|
if cfg.Shell.Alias != "" {
|
||||||
|
fmt.Fprintf(&b, "\nshell:\n alias: %q\n", cfg.Shell.Alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.WriteFile(path, []byte(b.String()), 0o600); err != nil {
|
||||||
return fmt.Errorf("writing config: %w", err)
|
return fmt.Errorf("writing config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -366,3 +366,23 @@ func TestSave_RoundtripViaLoad(t *testing.T) {
|
|||||||
t.Errorf("TTL: got %d, want %d", loaded.Cache.TTL, original.Cache.TTL)
|
t.Errorf("TTL: got %d, want %d", loaded.Cache.TTL, original.Cache.TTL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveCommandAliasRoundtripAndDisable(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
cfg := config.Config{Shell: config.ShellConfig{Alias: "j"}, Cache: config.CacheConfig{Path: filepath.Join(t.TempDir(), "hosts.json")}}
|
||||||
|
if err := save(cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := config.Load()
|
||||||
|
if err != nil || loaded.Shell.Alias != "j" || loaded.Cache.Path != cfg.Cache.Path {
|
||||||
|
t.Fatalf("roundtrip: %+v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
cfg.Shell.Alias = ""
|
||||||
|
if err := save(cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err = config.Load()
|
||||||
|
if err != nil || loaded.Shell.Alias != "" {
|
||||||
|
t.Fatalf("disable: %+v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,9 @@ func ReplaceHost(args []string, destIdx int, newHost string) []string {
|
|||||||
|
|
||||||
// HasPortFlag reports whether a port was specified via -p in args.
|
// HasPortFlag reports whether a port was specified via -p in args.
|
||||||
func HasPortFlag(args []string) bool {
|
func HasPortFlag(args []string) bool {
|
||||||
|
if parsed := Parse(args); parsed != nil {
|
||||||
|
args = args[:parsed.DestIdx]
|
||||||
|
}
|
||||||
for i, a := range args {
|
for i, a := range args {
|
||||||
if a == "-p" && i+1 < len(args) {
|
if a == "-p" && i+1 < len(args) {
|
||||||
return true
|
return true
|
||||||
@@ -102,6 +105,9 @@ func HasPortFlag(args []string) bool {
|
|||||||
// HasUserFlag reports whether a user was specified via -l in args.
|
// HasUserFlag reports whether a user was specified via -l in args.
|
||||||
// Used to avoid overriding an explicit -l with the configured default user.
|
// Used to avoid overriding an explicit -l with the configured default user.
|
||||||
func HasUserFlag(args []string) bool {
|
func HasUserFlag(args []string) bool {
|
||||||
|
if parsed := Parse(args); parsed != nil {
|
||||||
|
args = args[:parsed.DestIdx]
|
||||||
|
}
|
||||||
for i, a := range args {
|
for i, a := range args {
|
||||||
if a == "-l" && i+1 < len(args) {
|
if a == "-l" && i+1 < len(args) {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -190,3 +190,10 @@ func assertParsed(t *testing.T, got *ParsedArgs, host, user string, destIdx int)
|
|||||||
t.Errorf("destIdx: got %d, want %d", got.DestIdx, destIdx)
|
t.Errorf("destIdx: got %d, want %d", got.DestIdx, destIdx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoteCommandFlagsDoNotOverrideDefaults(t *testing.T) {
|
||||||
|
args := []string{"web01", "command", "-p", "1234", "-lroot"}
|
||||||
|
if HasPortFlag(args) || HasUserFlag(args) {
|
||||||
|
t.Fatal("remote command interpreted as SSH options")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Package storage provides atomic file replacement in the destination directory.
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WriteFile(path string, data []byte, mode os.FileMode) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.CreateTemp(filepath.Dir(path), ".netssh-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(f.Name())
|
||||||
|
defer f.Close()
|
||||||
|
if err := f.Chmod(mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := f.Write(data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(f.Name(), path)
|
||||||
|
}
|
||||||
+180
-40
@@ -8,7 +8,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
|
"github.com/charmbracelet/bubbles/textarea"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -19,6 +21,7 @@ import (
|
|||||||
|
|
||||||
// SelectedHost is returned when the user confirms a host in the TUI.
|
// SelectedHost is returned when the user confirms a host in the TUI.
|
||||||
type SelectedHost struct {
|
type SelectedHost struct {
|
||||||
|
Kind string
|
||||||
Name string
|
Name string
|
||||||
IP string
|
IP string
|
||||||
User string // empty = use config default
|
User string // empty = use config default
|
||||||
@@ -27,10 +30,19 @@ type SelectedHost struct {
|
|||||||
|
|
||||||
// --- bubbletea messages ---
|
// --- bubbletea messages ---
|
||||||
|
|
||||||
type debounceMsg struct{ query string }
|
type debounceMsg struct {
|
||||||
|
query string
|
||||||
|
seq int
|
||||||
|
}
|
||||||
|
type noteResultMsg struct {
|
||||||
|
note notes.Note
|
||||||
|
err error
|
||||||
|
saved bool
|
||||||
|
}
|
||||||
|
|
||||||
type searchResultMsg struct {
|
type searchResultMsg struct {
|
||||||
query string
|
query string
|
||||||
|
seq int
|
||||||
entries []netbox.HostEntry
|
entries []netbox.HostEntry
|
||||||
err error
|
err error
|
||||||
recent bool // true when this is a recently-used list, not a search result
|
recent bool // true when this is a recently-used list, not a search result
|
||||||
@@ -40,6 +52,7 @@ type searchResultMsg struct {
|
|||||||
|
|
||||||
type hostItem struct {
|
type hostItem struct {
|
||||||
name string
|
name string
|
||||||
|
id int
|
||||||
ip string
|
ip string
|
||||||
kind string
|
kind string
|
||||||
tags []string
|
tags []string
|
||||||
@@ -120,27 +133,35 @@ func applyFilter(entries []netbox.HostEntry, f filterOpts) []netbox.HostEntry {
|
|||||||
// --- Model ---
|
// --- Model ---
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
input textinput.Model
|
noteErr error
|
||||||
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
|
editHost *hostItem
|
||||||
editInput textinput.Model // user@host:port inline editor
|
noteInput textarea.Model
|
||||||
list list.Model
|
noteService *notes.Service
|
||||||
client *netbox.Client
|
note notes.Note
|
||||||
cache *cache.Cache
|
noteMode, noteRemote, noteBusy bool
|
||||||
defaultUser string
|
noteLoaded bool
|
||||||
lastSent string // last query sent to NetBox (or served from cache)
|
defaultPort int
|
||||||
lastResults []netbox.HostEntry // raw results before filter applied
|
input textinput.Model
|
||||||
seq int // sequence number to discard stale results
|
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
|
||||||
loading bool
|
editInput textinput.Model // user@host:port inline editor
|
||||||
err error
|
list list.Model
|
||||||
selected *SelectedHost
|
client *netbox.Client
|
||||||
width int
|
cache *cache.Cache
|
||||||
height int
|
defaultUser string
|
||||||
recentMode bool // true when showing recent hosts (empty search, initial state)
|
lastSent string // last query sent to NetBox (or served from cache)
|
||||||
filterOpen bool // Ctrl+F toggles
|
lastResults []netbox.HostEntry // raw results before filter applied
|
||||||
editMode bool // 'e' on selected item
|
seq int // sequence number to discard stale results
|
||||||
|
loading bool
|
||||||
|
err error
|
||||||
|
selected *SelectedHost
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
recentMode bool // true when showing recent hosts (empty search, initial state)
|
||||||
|
filterOpen bool // Ctrl+F toggles
|
||||||
|
editMode bool // Ctrl+E on the selected item
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(client *netbox.Client, c *cache.Cache, defaultUser string) *Model {
|
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
|
||||||
ti := textinput.New()
|
ti := textinput.New()
|
||||||
ti.Placeholder = "Search hostname…"
|
ti.Placeholder = "Search hostname…"
|
||||||
ti.Focus()
|
ti.Focus()
|
||||||
@@ -157,8 +178,18 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string) *Model {
|
|||||||
l.SetShowTitle(false)
|
l.SetShowTitle(false)
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
l.SetFilteringEnabled(false)
|
l.SetFilteringEnabled(false)
|
||||||
|
l.DisableQuitKeybindings()
|
||||||
|
port := 22
|
||||||
|
if len(defaultPorts) > 0 && defaultPorts[0] > 0 {
|
||||||
|
port = defaultPorts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
ni := textarea.New()
|
||||||
|
ni.CharLimit = 0
|
||||||
|
ni.SetHeight(10)
|
||||||
return &Model{
|
return &Model{
|
||||||
|
noteInput: ni,
|
||||||
|
defaultPort: port,
|
||||||
input: ti,
|
input: ti,
|
||||||
filterInput: fi,
|
filterInput: fi,
|
||||||
editInput: ei,
|
editInput: ei,
|
||||||
@@ -191,6 +222,20 @@ func (m *Model) loadRecent() tea.Cmd {
|
|||||||
|
|
||||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case noteResultMsg:
|
||||||
|
m.noteBusy = false
|
||||||
|
m.noteErr = msg.err
|
||||||
|
if msg.err == nil {
|
||||||
|
m.noteLoaded = true
|
||||||
|
m.note = msg.note
|
||||||
|
m.noteInput.SetValue(msg.note.Text)
|
||||||
|
if msg.saved {
|
||||||
|
m.noteMode = false
|
||||||
|
m.noteInput.Blur()
|
||||||
|
m.input.Focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
@@ -202,10 +247,43 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.editMode {
|
if m.editMode {
|
||||||
extraRows += 2
|
extraRows += 2
|
||||||
}
|
}
|
||||||
m.list.SetSize(msg.Width, msg.Height-extraRows)
|
m.list.SetSize(msg.Width, max(1, msg.Height-extraRows))
|
||||||
|
m.noteInput.SetWidth(max(10, msg.Width-4))
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
|
if msg.String() == "ctrl+c" {
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
if m.noteMode {
|
||||||
|
if m.noteBusy {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc":
|
||||||
|
m.noteMode = false
|
||||||
|
m.noteInput.Blur()
|
||||||
|
m.input.Focus()
|
||||||
|
m.noteErr = nil
|
||||||
|
return m, nil
|
||||||
|
case "ctrl+s":
|
||||||
|
if !m.noteLoaded {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
note := m.note
|
||||||
|
note.Text = m.noteInput.Value()
|
||||||
|
remote, service := m.noteRemote, m.noteService
|
||||||
|
m.noteBusy = true
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return noteResultMsg{note: note, saved: true, err: service.Write(ctx, note, remote)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.noteInput, cmd = m.noteInput.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
// --- Edit mode ---
|
// --- Edit mode ---
|
||||||
if m.editMode {
|
if m.editMode {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
@@ -215,7 +293,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.input.Focus()
|
m.input.Focus()
|
||||||
return m, nil
|
return m, nil
|
||||||
case "enter":
|
case "enter":
|
||||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
if m.editHost != nil {
|
||||||
|
item := *m.editHost
|
||||||
|
value := m.editInput.Value()
|
||||||
|
if idx := strings.LastIndex(value, ":"); idx >= 0 {
|
||||||
|
port, err := strconv.Atoi(strings.TrimSpace(value[idx+1:]))
|
||||||
|
if err != nil || port < 1 || port > 65535 {
|
||||||
|
m.err = fmt.Errorf("port must be between 1 and 65535")
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
m.selected = m.buildSelected(item)
|
m.selected = m.buildSelected(item)
|
||||||
}
|
}
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -261,21 +348,44 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case "enter":
|
case "enter":
|
||||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||||
m.selected = &SelectedHost{Name: item.name, IP: item.ip}
|
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
|
|
||||||
case "e":
|
case "ctrl+e":
|
||||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||||
m.openEdit(item)
|
m.openEdit(item)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "ctrl+n", "ctrl+b":
|
||||||
|
if item, ok := m.list.SelectedItem().(hostItem); ok && m.noteService != nil {
|
||||||
|
m.noteMode = true
|
||||||
|
m.noteLoaded = false
|
||||||
|
m.noteBusy = true
|
||||||
|
m.noteRemote = msg.String() == "ctrl+b"
|
||||||
|
m.input.Blur()
|
||||||
|
m.noteInput.Focus()
|
||||||
|
m.noteInput.SetValue("")
|
||||||
|
m.noteErr = nil
|
||||||
|
host := netbox.HostEntry{ID: item.id, Name: item.name, Kind: item.kind}
|
||||||
|
m.note = notes.Note{Host: host}
|
||||||
|
remote, service := m.noteRemote, m.noteService
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
note, err := service.Read(ctx, host, remote)
|
||||||
|
return noteResultMsg{note: note, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
case "tab":
|
case "tab":
|
||||||
if m.list.Items() != nil && len(m.list.Items()) > 0 {
|
if m.list.Items() != nil && len(m.list.Items()) > 0 {
|
||||||
if item, ok := m.list.Items()[0].(hostItem); ok {
|
if item, ok := m.list.Items()[0].(hostItem); ok {
|
||||||
m.input.SetValue(item.name)
|
m.input.SetValue(item.name)
|
||||||
m.input.CursorEnd()
|
m.input.CursorEnd()
|
||||||
|
m.seq++
|
||||||
|
return m, m.startDebounce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -283,24 +393,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case debounceMsg:
|
case debounceMsg:
|
||||||
q := m.input.Value()
|
q := m.input.Value()
|
||||||
if q == m.lastSent {
|
if msg.seq != m.seq || msg.query != q {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.lastSent = q
|
m.lastSent = q
|
||||||
m.recentMode = false
|
m.recentMode = false
|
||||||
m.loading = true
|
m.loading = true
|
||||||
m.seq++
|
|
||||||
seq := m.seq
|
seq := m.seq
|
||||||
return m, m.doSearch(q, seq)
|
return m, m.doSearch(q, seq)
|
||||||
|
|
||||||
case searchResultMsg:
|
case searchResultMsg:
|
||||||
if msg.recent {
|
if msg.recent {
|
||||||
|
if m.input.Value() != "" || m.seq > 0 {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
m.recentMode = true
|
m.recentMode = true
|
||||||
m.lastResults = msg.entries
|
m.lastResults = msg.entries
|
||||||
m.updateListItems(msg.entries)
|
m.updateListItems(msg.entries)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if msg.query != m.lastSent {
|
if msg.seq != m.seq || msg.query != m.input.Value() {
|
||||||
return m, nil // discard stale result
|
return m, nil // discard stale result
|
||||||
}
|
}
|
||||||
m.loading = false
|
m.loading = false
|
||||||
@@ -314,12 +426,21 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.noteMode {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.noteInput, cmd = m.noteInput.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
// Forward to search input and restart debounce timer.
|
// Forward to search input and restart debounce timer.
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
var inputCmd tea.Cmd
|
var inputCmd tea.Cmd
|
||||||
|
oldQuery := m.input.Value()
|
||||||
m.input, inputCmd = m.input.Update(msg)
|
m.input, inputCmd = m.input.Update(msg)
|
||||||
cmds = append(cmds, inputCmd)
|
cmds = append(cmds, inputCmd)
|
||||||
cmds = append(cmds, m.startDebounce())
|
if m.input.Value() != oldQuery {
|
||||||
|
m.seq++
|
||||||
|
cmds = append(cmds, m.startDebounce())
|
||||||
|
}
|
||||||
|
|
||||||
var listCmd tea.Cmd
|
var listCmd tea.Cmd
|
||||||
m.list, listCmd = m.list.Update(msg)
|
m.list, listCmd = m.list.Update(msg)
|
||||||
@@ -329,6 +450,21 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) View() string {
|
func (m *Model) View() string {
|
||||||
|
if m.noteMode {
|
||||||
|
target := "local"
|
||||||
|
if m.noteRemote {
|
||||||
|
target = "NetBox comments"
|
||||||
|
}
|
||||||
|
status := "ctrl+s save esc cancel"
|
||||||
|
if m.noteBusy {
|
||||||
|
status = "Loading / saving…"
|
||||||
|
}
|
||||||
|
errText := ""
|
||||||
|
if m.noteErr != nil {
|
||||||
|
errText = "\nError: " + m.noteErr.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Notes [%s] — %s\n\n%s\n%s%s\n", target, m.note.Host.Name, m.noteInput.View(), status, errText)
|
||||||
|
}
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("netssh")
|
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("netssh")
|
||||||
@@ -358,7 +494,7 @@ func (m *Model) View() string {
|
|||||||
sb.WriteString(m.editInput.View() + "\n")
|
sb.WriteString(m.editInput.View() + "\n")
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
|
||||||
} else {
|
} else {
|
||||||
hint := "ctrl+c quit enter connect e edit ctrl+f filter"
|
hint := "ctrl+c quit enter connect ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
|
||||||
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
|
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,8 +507,9 @@ func (m *Model) Selected() *SelectedHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) startDebounce() tea.Cmd {
|
func (m *Model) startDebounce() tea.Cmd {
|
||||||
|
query, seq := m.input.Value(), m.seq
|
||||||
return tea.Tick(300*time.Millisecond, func(_ time.Time) tea.Msg {
|
return tea.Tick(300*time.Millisecond, func(_ time.Time) tea.Msg {
|
||||||
return debounceMsg{query: m.input.Value()}
|
return debounceMsg{query: query, seq: seq}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,20 +523,19 @@ func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
|||||||
for i, c := range cached {
|
for i, c := range cached {
|
||||||
entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind, Tags: c.Tags}
|
entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind, Tags: c.Tags}
|
||||||
}
|
}
|
||||||
return searchResultMsg{query: query, entries: entries}
|
return searchResultMsg{query: query, seq: seq, entries: entries}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.client == nil {
|
if m.client == nil {
|
||||||
return searchResultMsg{query: query, entries: nil}
|
return searchResultMsg{query: query, seq: seq, entries: nil}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
entries, err := m.client.Search(ctx, query, opts)
|
entries, err := m.client.Search(ctx, query, opts)
|
||||||
_ = seq
|
return searchResultMsg{query: query, seq: seq, entries: entries, err: err}
|
||||||
return searchResultMsg{query: query, entries: entries, err: err}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +559,7 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
|||||||
if ip == "" {
|
if ip == "" {
|
||||||
ip = e.PrimaryIP6
|
ip = e.PrimaryIP6
|
||||||
}
|
}
|
||||||
items[i] = hostItem{name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags}
|
items[i] = hostItem{id: e.ID, name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags}
|
||||||
}
|
}
|
||||||
m.list.SetItems(items)
|
m.list.SetItems(items)
|
||||||
}
|
}
|
||||||
@@ -431,13 +567,15 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
|||||||
// openEdit switches to edit mode for the given list item.
|
// openEdit switches to edit mode for the given list item.
|
||||||
func (m *Model) openEdit(item hostItem) {
|
func (m *Model) openEdit(item hostItem) {
|
||||||
m.editMode = true
|
m.editMode = true
|
||||||
|
m.editHost = &item
|
||||||
m.input.Blur()
|
m.input.Blur()
|
||||||
|
|
||||||
user := m.defaultUser
|
user := m.defaultUser
|
||||||
if user == "" {
|
prefix := ""
|
||||||
user = item.name
|
if user != "" {
|
||||||
|
prefix = user + "@"
|
||||||
}
|
}
|
||||||
m.editInput.SetValue(fmt.Sprintf("%s@%s:22", user, item.name))
|
m.editInput.SetValue(fmt.Sprintf("%s%s:%d", prefix, item.name, m.defaultPort))
|
||||||
m.editInput.CursorEnd()
|
m.editInput.CursorEnd()
|
||||||
m.editInput.Focus()
|
m.editInput.Focus()
|
||||||
}
|
}
|
||||||
@@ -445,7 +583,7 @@ func (m *Model) openEdit(item hostItem) {
|
|||||||
// buildSelected parses the editInput to extract user/port overrides.
|
// buildSelected parses the editInput to extract user/port overrides.
|
||||||
func (m *Model) buildSelected(item hostItem) *SelectedHost {
|
func (m *Model) buildSelected(item hostItem) *SelectedHost {
|
||||||
s := strings.TrimSpace(m.editInput.Value())
|
s := strings.TrimSpace(m.editInput.Value())
|
||||||
sel := &SelectedHost{Name: item.name, IP: item.ip}
|
sel := &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
|
||||||
|
|
||||||
// Extract user: everything before @
|
// Extract user: everything before @
|
||||||
if idx := strings.Index(s, "@"); idx != -1 {
|
if idx := strings.Index(s, "@"); idx != -1 {
|
||||||
@@ -456,10 +594,12 @@ func (m *Model) buildSelected(item hostItem) *SelectedHost {
|
|||||||
// Extract port: after the last colon, if it's a number
|
// Extract port: after the last colon, if it's a number
|
||||||
if idx := strings.LastIndex(s, ":"); idx != -1 {
|
if idx := strings.LastIndex(s, ":"); idx != -1 {
|
||||||
portStr := strings.TrimSpace(s[idx+1:])
|
portStr := strings.TrimSpace(s[idx+1:])
|
||||||
if _, err := strconv.Atoi(portStr); err == nil && portStr != "22" {
|
if port, err := strconv.Atoi(portStr); err == nil && port > 0 && port <= 65535 {
|
||||||
sel.Port = portStr
|
sel.Port = portStr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sel
|
return sel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTypingEDoesNotOpenConnectionEditor(t *testing.T) {
|
||||||
|
m := New(nil, nil, "")
|
||||||
|
m.updateListItems([]netbox.HostEntry{{Name: "server"}})
|
||||||
|
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
|
||||||
|
if m.editMode || m.input.Value() != "e" {
|
||||||
|
t.Fatal("e intercepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaleResultsAndDebounceAreIgnored(t *testing.T) {
|
||||||
|
m := New(nil, nil, "")
|
||||||
|
m.input.SetValue("web")
|
||||||
|
m.seq = 3
|
||||||
|
m.Update(searchResultMsg{query: "web", seq: 1, entries: []netbox.HostEntry{{Name: "old"}}})
|
||||||
|
if len(m.list.Items()) != 0 {
|
||||||
|
t.Fatal("stale result accepted")
|
||||||
|
}
|
||||||
|
_, cmd := m.Update(debounceMsg{query: "w", seq: 1})
|
||||||
|
if cmd != nil {
|
||||||
|
t.Fatal("stale debounce accepted")
|
||||||
|
}
|
||||||
|
m.Update(searchResultMsg{query: "web", seq: 3, entries: []netbox.HostEntry{{Name: "web01"}}})
|
||||||
|
if len(m.list.Items()) != 1 {
|
||||||
|
t.Fatal("current result ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditPreservesDefaultAndExplicitPort22(t *testing.T) {
|
||||||
|
m := New(nil, nil, "", 2222)
|
||||||
|
item := hostItem{name: "web01", ip: "10.0.0.1"}
|
||||||
|
m.openEdit(item)
|
||||||
|
if m.editInput.Value() != "web01:2222" {
|
||||||
|
t.Fatal(m.editInput.Value())
|
||||||
|
}
|
||||||
|
m.editInput.SetValue("ops@web01:22")
|
||||||
|
selected := m.buildSelected(item)
|
||||||
|
if selected.Port != "22" || selected.User != "ops" {
|
||||||
|
t.Fatalf("bad override: %+v", selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailedNoteLoadCannotBeSaved(t *testing.T) {
|
||||||
|
m := New(nil, nil, "")
|
||||||
|
m.SetNotes(¬es.Service{Dir: t.TempDir()})
|
||||||
|
m.noteMode = true
|
||||||
|
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
|
||||||
|
if cmd != nil {
|
||||||
|
t.Fatal("save allowed before successful load")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectionEditorKeepsOriginalHost(t *testing.T) {
|
||||||
|
m := New(nil, nil, "admin")
|
||||||
|
first := hostItem{name: "first", ip: "10.0.0.1"}
|
||||||
|
m.openEdit(first)
|
||||||
|
m.updateListItems([]netbox.HostEntry{{Name: "second", PrimaryIP4: "10.0.0.2"}})
|
||||||
|
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
if m.selected == nil || m.selected.Name != "first" {
|
||||||
|
t.Fatalf("edited host changed: %+v", m.selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user