Compare commits

..
19 Commits
Author SHA1 Message Date
Sebastian Unterschütz a2a6dd7591 chore(deps): update github.com/charmbracelet/ultraviolet digest to a45b7c8
Release / release (push) Successful in 5m7s
2026-10-27 22:44:00 +01:00
Sebastian Unterschütz 79c0027833 fix(deps): migrate Charm to v2 and require Go 1.27.3
Release / release (push) Successful in 6m16s
2026-10-27 22:29:54 +01:00
Sebastian Unterschütz fa7446e5f7 Fix host resolution and concurrent cache updates 2026-10-27 22:23:59 +01:00
Sebastian Unterschütz 92b1c40cb8 Update Go module dependencies
Release / release (push) Successful in 4m46s
2026-10-27 19:25:37 +01:00
Sebastian Unterschütz 34503de68e Enable shell completion for configured command aliases
Release / release (push) Successful in 5m8s
2026-10-27 19:19:42 +01:00
Sebastian Unterschütz 14d519d010 Use public Gitea URL for releases and download links
Release / release (push) Successful in 5m37s
2026-10-27 18:46:38 +01:00
Sebastian Unterschütz fa4509038b Package installer in releases and expand Renovate dependency updates
Release / release (push) Successful in 4m41s
2026-10-27 18:38:54 +01:00
Sebastian Unterschütz 66a49786c9 Translate remaining German text to English
Release / release (push) Successful in 5m32s
2026-10-27 18:27:21 +01:00
Sebastian Unterschütz 4b28d441b0 Improve installer updates and dependency checks and add command aliases
Release / release (push) Successful in 7m57s
2026-10-27 18:10:44 +01:00
Sebastian Unterschütz 12cc64fdd9 Add local and NetBox server notes and improve reliability
Release / release (push) Successful in 6m52s
2026-10-27 17:51:59 +01:00
Sebi 2852dff2c4 Merge pull request 'chore(deps): update actions/setup-go action to v7' (#3) from renovate/actions-setup-go-7.x into main 2026-10-11 17:15:15 +00:00
Sebi 2d11303014 Merge pull request 'chore(deps): update actions/checkout action to v7' (#2) from renovate/actions-checkout-7.x into main 2026-10-11 17:13:57 +00:00
Sebi 5c6cc7dbbf chore(deps): update actions/setup-go action to v7 2026-10-11 16:30:19 +00:00
Sebi b17ace7fc5 chore(deps): update actions/checkout action to v7 2026-10-11 16:29:56 +00:00
Sebi 8241a064f4 Merge pull request 'chore: Configure Renovate' (#1) from renovate/configure into main 2026-10-11 16:12:43 +00:00
Sebi 86fc2f19eb Add renovate.json 2026-10-11 15:45:13 +00:00
Sebastian Unterschütz fa646f25a6 feat: add alias file generation and management for shell hooks
Release / release (push) Successful in 49s
- **Aliases**: Generate shell alias files (`aliases.sh` for bash/zsh, `netssh.fish` for fish) from cached hosts. Regenerate on each shell startup and cache refresh to keep aliases updated.
- **Hooks**: Extend shell hook functionality to include alias file support. Install and uninstall commands updated for bash, zsh, and fish.
- **Tests**: Add unit tests to verify alias file generation, path resolution, and idempotent hook installation.
- **Docs**: Update README with instructions for alias file usage, installation, and relation to hooks.
2026-05-27 23:08:30 +02:00
Sebastian Unterschütz 7c902cab3a feat: introduce shortcuts and shell hook support
Release / release (push) Successful in 50s
- **Shortcuts**: Add hostname normalization with domain stripping and hyphen folding. Include alias generation for cached hosts.
- **Shell Hook**: Automate 24h cache refresh trigger with shell startup hook. Add install/uninstall commands for bash, zsh, and fish.
- **Wizard**: Extend setup wizard to configure shortcuts (domains, hyphen stripping) and default SSH port.
- **Cache**: Add `GetByShortcut` for resolving hosts via normalized shortcuts. Implement `NeedsRefresh` / `SetRefreshed` logic for refresh timestamps.
- **Tests**: Comprehensive unit tests for shortcuts, hook installation, cache refresh, and alias generation.
- **Docs**: Update README with shortcuts, shell hook, and default SSH port configuration.
2026-05-27 22:53:24 +02:00
Sebastian Unterschütz d127a3b957 feat: enhance host resolution, filtering, and cache management
Release / release (push) Successful in 49s
- **Strategies**: Add resolver strategy input validation and parsing in setup wizard. Support comma-separated input with known strategy mapping.
- **Client**: Extend Search and SearchAll to include kind and tag filters. Add pagination for full cache refresh handling large datasets.
- **Cache**: Introduce `RecentlyUsed` and `MarkUsed`. Persist `LastUsed` timestamps for entries.
- **TUI**: Add recent hosts view, tag/kind filters, and inline editor for user/port override.
- **Tests**: Comprehensive unit tests for new features, including strategy validation, cache behavior, and client filtering.
- **Docs**: Update README with new TUI features and cache subcommands.
2026-05-23 17:06:24 +02:00
41 changed files with 5629 additions and 331 deletions

No files matched your search

+39 -14
View File
@@ -8,11 +8,16 @@ on:
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
GITEA_PUBLIC_URL: https://git.zb-server.de
GITEA_REPOSITORY: ${{ github.repository }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
with:
fetch-depth: 0
- 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
@@ -33,15 +38,24 @@ jobs:
-o "$out" ./cmd/netssh -o "$out" ./cmd/netssh
done done
- name: Package installer
run: install -m 755 install.sh dist/install.sh
- name: Generate checksums - name: Generate checksums
working-directory: dist working-directory: dist
run: sha256sum netssh_* > checksums.txt run: sha256sum netssh_* install.sh > checksums.txt
- name: Generate release notes
env:
RELEASE_TAG: ${{ github.ref_name }}
run: bash scripts/release-notes.sh "$RELEASE_TAG" "$GITEA_PUBLIC_URL/$GITEA_REPOSITORY" > dist/release-notes.md
- name: Create release - name: Create release
id: create_release id: create_release
env:
TAG: ${{ github.ref_name }}
run: | run: |
TAG="${{ github.ref_name }}" API="$GITEA_PUBLIC_URL/api/v1/repos/$GITEA_REPOSITORY"
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
AUTH="Authorization: token ${{ secrets.GITEA_TOKEN }}" AUTH="Authorization: token ${{ secrets.GITEA_TOKEN }}"
# Delete existing release for this tag if present # Delete existing release for this tag if present
@@ -51,17 +65,28 @@ jobs:
curl -sf -X DELETE -H "$AUTH" "$API/releases/$EXISTING" curl -sf -X DELETE -H "$AUTH" "$API/releases/$EXISTING"
fi fi
# Serialize Markdown as JSON without hand-escaping quotes or newlines.
python3 - <<'PY_RELEASE' > dist/release.json
import json
import os
import sys
from pathlib import Path
tag = os.environ["TAG"]
json.dump({
"tag_name": tag,
"name": tag,
"body": Path("dist/release-notes.md").read_text(),
"draft": False,
"prerelease": False,
}, sys.stdout)
PY_RELEASE
RELEASE=$(curl -sf -X POST \ RELEASE=$(curl -sf -X POST \
-H "$AUTH" \ -H "$AUTH" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$API/releases" \ "$API/releases" \
-d "{ --data-binary @dist/release.json)
\"tag_name\": \"$TAG\",
\"name\": \"$TAG\",
\"body\": \"## Installation\n\n\`\`\`sh\ncurl -fsSL https://git.zb-server.de/Sebi/ssh-netbox-wrapper/raw/branch/main/install.sh | bash\n\`\`\`\",
\"draft\": false,
\"prerelease\": false
}")
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT" echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
@@ -69,12 +94,12 @@ jobs:
- name: Upload assets - name: Upload assets
run: | run: |
RELEASE_ID="${{ steps.create_release.outputs.release_id }}" RELEASE_ID="${{ steps.create_release.outputs.release_id }}"
for file in dist/netssh_* dist/checksums.txt; do for file in dist/netssh_* dist/install.sh dist/checksums.txt; do
name=$(basename "$file") name=$(basename "$file")
echo "↑ $name" echo "↑ $name"
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets?name=${name}" \ "$GITEA_PUBLIC_URL/api/v1/repos/$GITEA_REPOSITORY/releases/${RELEASE_ID}/assets?name=${name}" \
--data-binary "@$file" --data-binary "@$file"
done done
+71
View File
@@ -0,0 +1,71 @@
# Changelog
## v0.0.18
- Require Go 1.27.3 and migrate Bubbles, Bubble Tea, Huh, and Lip Gloss to their v2 modules under `charm.land`.
- Update TUI rendering and keyboard events for Bubble Tea v2 while preserving alternate-screen behavior.
- Route paste events to the focused editor, size input fields explicitly, and keep host lists within the terminal height.
- Reject ambiguous cached shortcuts and skip duplicate hostnames during cache refresh.
- Use paginated, unambiguous host lookups for SSH connections.
- Accept shortcut domains with or without a leading dot during cold lookups.
- Parse combined SSH options and preserve explicit users and ports supplied through `-o`.
- Merge cache changes under a file lock to preserve updates from concurrent processes.
- Honor configured resolver strategies during refresh and remove entries that no longer resolve safely.
- Check the host kind before reusing a cached IP for a TUI selection.
## v0.0.17
- Update Go module dependencies to the latest versions within their existing module paths, including used transitive test dependencies.
- Remove obsolete indirect requirements and refresh module checksums with `go mod tidy`.
## v0.0.16
- Enable Bash, Zsh, and Fish completion for the configured command alias, such as `j`.
- Update or remove generated alias completion files when the command alias changes.
## v0.0.15
- Use `https://git.zb-server.de` for release download links and API requests instead of the runner's internal Gitea service URL.
## v0.0.14
- Enable Renovate updates for indirect Go modules as well as direct dependencies and CI actions.
- Enable Go version updates and keep Go module metadata tidy after dependency updates.
- Correct direct and indirect dependency declarations in `go.mod`.
- Include `install.sh` as a downloadable release asset.
- Include the installer in `checksums.txt` alongside the platform binaries.
- Link the installation command in release notes directly to the release's installer asset.
- Generate a change list from commits since the previous version tag for every release.
## v0.0.13
- Translate the remaining German setup message and test example into English.
- Correct the connection editor comment to reference `Ctrl+E`.
## v0.0.12
- Support updating an existing installation by running the installer again.
- Remove the installer's Python dependency.
- Require valid SHA-256 checksums before replacing the binary.
- Check required programs before downloading and list missing dependencies.
- Check for `sudo` before downloading when the installation directory requires elevated permissions.
- Consolidate legacy shell hooks and aliases in Bash, Zsh, and Fish profiles.
- Back up changed profiles and preserve symlinks and file permissions.
- Regenerate aliases and completions during installation and updates.
- Add a configurable command alias, such as `j`, to the setup wizard.
- Refresh or remove the generated command alias when its configuration changes.
## v0.0.11
- Add multiline local server notes and optional NetBox comments in the TUI.
- Add CLI commands to show, replace, and append server notes.
- Keep local notes separate from the host cache, with private permissions and atomic writes.
- Use NetBox ETags for comment conflict protection when supported by the server.
- Correct authentication for NetBox v2 tokens.
- Fix environment variable overrides for configuration keys.
- Preserve usage history when refreshing cached hosts.
- Discard stale TUI search results and use `Ctrl+E` for connection editing.
- Validate port overrides and preserve an explicit port 22.
- Resolve stale and live TUI selections through the configured resolver chain.
- Reject incomplete full cache refreshes and avoid marking filtered refreshes as full refreshes.
- Stop interpreting remote command flags as SSH options when applying defaults.
+344 -8
View File
@@ -1,5 +1,7 @@
# netssh # netssh
> **Vibe-coded project** — this codebase was written entirely by an AI assistant (Claude) without human code review. Use in production at your own risk.
A transparent SSH wrapper that resolves hostnames via [NetBox](https://netbox.dev/) before connecting. A transparent SSH wrapper that resolves hostnames via [NetBox](https://netbox.dev/) before connecting.
Instead of looking up an IP manually, you just type the hostname as it appears in NetBox: Instead of looking up an IP manually, you just type the hostname as it appears in NetBox:
@@ -16,10 +18,20 @@ netssh -p 2222 admin@app-server-03 uptime
- **Transparent proxy** — replaces itself with `ssh` via `syscall.Exec`, preserving all SSH flags and options - **Transparent proxy** — replaces itself with `ssh` via `syscall.Exec`, preserving all SSH flags and options
- **Flexible IP resolution** — configurable chain of strategies: management subnet, primary IP, or named interface - **Flexible IP resolution** — configurable chain of strategies: management subnet, primary IP, or named interface
- **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
- **Tag/kind filter** — press `Ctrl+F` in the TUI to filter by `tag:prod` or `kind:vm`
- **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)
- **Selective refresh** — `cache refresh --tag prod --kind vm` limits what gets synced
- **Setup wizard** — interactive first-run onboarding; re-run anytime with `netssh configure` - **Setup wizard** — interactive first-run onboarding; re-run anytime with `netssh configure`
- **Shell completion** — install without sudo via `netssh completion install` - **Shell completion** — install without sudo via `netssh completion install`
- **Default SSH user** — set a fallback username once in config instead of typing it every time - **Default SSH user** — set a fallback username once in config instead of typing it every time
- **Default SSH port** — set a fallback port in config; injected as `-p` when not specified on the command line
- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip and hyphen folding
- **Shell aliases** — `netssh alias` generates shell aliases for all cached hosts
- **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours
## Installation ## Installation
@@ -34,9 +46,44 @@ 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.
### Release downloads and change history
Each new release includes `install.sh`, binaries for Linux and macOS (amd64 and
arm64), and `checksums.txt` covering all of these files. The release notes include
an installation command pointing directly to that release's `install.sh` asset
and a list of changes since the previous version tag.
See [CHANGELOG.md](CHANGELOG.md) for the change history. The installer attached to
a release installs or updates to the latest published binary release.
### Build from source ### Build from source
```sh ```sh
@@ -56,7 +103,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
@@ -87,10 +134,49 @@ cache:
ssh: ssh:
default_user: admin # used when no user is specified on the command line default_user: admin # used when no user is specified on the command line
default_port: 2222 # optional; injected as -p if not specified on the command line
shortcuts:
domains: # strip these suffixes to create short aliases
- .example.com
strip_hyphens: false # if true, fsn1-web01 → fsn1web01 (cache-only resolution)
``` ```
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`.
The command alias also supports shell completion: `j ser<Tab>` suggests cached
server names, just like `netssh ser<Tab>`. The wizard, shell hook, installer, and
`netssh completion install` maintain the alias completion files automatically.
Changing or disabling the alias removes its old generated completion files.
Bash requires bash-completion to load user completion files; for Zsh, include
`~/.zfunc` in `fpath` before running `compinit` as described below. Fish loads
completion files automatically. Open a new terminal after changing the alias.
### API tokens ### API tokens
NetBox supports two token formats: NetBox supports two token formats:
@@ -132,6 +218,78 @@ netssh root@my-router # user@ prefix wins → ssh root@10.0.0.1
netssh -l ops my-router # -l flag wins → ssh -l ops 10.0.0.1 netssh -l ops my-router # -l flag wins → ssh -l ops 10.0.0.1
``` ```
### Default SSH port
Set `ssh.default_port` in the config to use a non-standard port without specifying it every time:
```sh
netssh my-router # → ssh -p 2222 -l admin 10.0.0.1
```
The default is only applied when `-p` is not already present in the SSH arguments. An explicit `-p` always takes precedence:
```sh
netssh -p 22 my-router # -p flag wins → ssh -p 22 10.0.0.1
```
### Host shortcuts
Configure short aliases so you can type `web01` instead of `web01.example.com`:
```yaml
shortcuts:
domains:
- .example.com
- .example.de
strip_hyphens: false # optional
```
Resolution order:
1. **Exact match** — the input is looked up as-is in the cache.
2. **Domain expansion** — if the input contains no dot, each configured domain is appended and tried (`web01` → `web01.example.com`). If no cache hit, NetBox is queried with the expanded name.
3. **Hyphen folding** (`strip_hyphens: true`) — hyphens are inserted back before looking up in the cache (`fsn1web01` → `fsn1-web01.example.com`). This only works when the host is already cached; run `netssh cache refresh` first.
Domains may be configured with or without a leading dot. If several cached hosts normalize to the same shortcut, the connection is rejected; use the full hostname. Live host lookups check every result page and reject duplicate exact names.
| Input | Config | Resolved as |
|-------|--------|-------------|
| `web01` | `domains: [.example.com]` | `web01.example.com` |
| `fsn1web01` | `domains: [.example.com]`, `strip_hyphens: true` | `fsn1-web01.example.com` (cache only) |
### Shell aliases
Generate shell aliases for all cached hosts based on the shortcut settings:
```sh
netssh alias # bash/zsh syntax
netssh alias --shell fish # fish syntax
```
Example output:
```bash
# netssh aliases (3 hosts) — source with: eval "$(netssh alias)"
alias db01='netssh db01.example.com'
alias fsn1web01='netssh fsn1-web01.example.com'
alias web01='netssh web01.example.com'
```
Add to your shell startup file:
```sh
# bash / zsh — in ~/.bashrc or ~/.zshrc
eval "$(netssh alias)"
# fish — in ~/.config/fish/config.fish
netssh alias --shell fish | source
# scripts
source <(netssh alias)
```
Without a `shortcuts` config, the alias name is derived from the full hostname with dots replaced by underscores (e.g. `web01_example_com`). If two hosts would produce the same alias name, the first entry wins and a warning is printed to stderr.
### Interactive TUI ### Interactive TUI
Run without arguments to open the interactive search: Run without arguments to open the interactive search:
@@ -140,22 +298,101 @@ Run without arguments to open the interactive search:
netssh netssh
``` ```
The TUI opens with your 10 most-recently-connected hosts. Start typing to search all cached hosts or query NetBox live.
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| type | filter hosts (300 ms debounce → NetBox query) | | type | filter hosts (300 ms debounce → NetBox query) |
| `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 |
| `Esc` / `Ctrl+C` | quit | | `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`) |
| `Esc` / `Ctrl+C` | quit (or close filter/edit if open) |
**Tag and kind filter** — press `Ctrl+F` to open a second input line:
```
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.
**User/port override** — press `Ctrl+E` on any highlighted host:
```
Connect as: admin@my-router:22
```
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
```sh ```sh
netssh cache list # show all cached entries netssh cache list # show all cached entries
netssh cache refresh # re-fetch all hosts from NetBox netssh cache refresh # re-fetch ALL hosts from NetBox (paginated)
netssh cache refresh --tag prod # only hosts with the "prod" tag
netssh cache refresh --kind vm # only virtual machines
netssh cache refresh --tag prod --kind vm # combine filters
netssh cache clear # wipe the cache netssh cache clear # wipe the cache
``` ```
The cache is refreshed automatically in the background every 24 hours. The trigger fires on the next SSH connect or TUI start after the interval has elapsed — there is no delay to the connection itself. The timestamp of the last refresh is stored in `~/.cache/netssh/last_refresh`.
To also trigger the check at shell startup (before you run any SSH command), install the shell hook:
```sh
netssh hook install # auto-detects $SHELL
netssh hook install --shell bash
netssh hook install --shell zsh
netssh hook install --shell fish
```
This appends a single line (`netssh shell-init`) to your shell profile. To remove it:
```sh
netssh hook uninstall
```
The hook is a no-op when the cache is fresh — it adds no measurable delay to your shell startup.
### Search (for scripting) ### Search (for scripting)
```sh ```sh
@@ -166,6 +403,8 @@ netssh search app- # prints matching hostnames, one per line
Strategies are tried in the configured order; the first to succeed wins. Strategies are tried in the configured order; the first to succeed wins.
Cache refresh uses the same strategies as a direct connection. To allow the primary IP as a fallback, include `primary_ip` in the strategy list. Hosts that cannot be resolved, or have ambiguous names in the refresh results, are removed from the cache and reported as warnings.
| Name | Description | | Name | Description |
|------|-------------| |------|-------------|
| `primary_ip` | Returns the `primary_ip4` (or `primary_ip6`) set in NetBox. No extra API call. | | `primary_ip` | Returns the `primary_ip4` (or `primary_ip6`) set in NetBox. No extra API call. |
@@ -198,20 +437,117 @@ autoload -Uz compinit && compinit
Completions are served from the local cache — no network request on every `<Tab>`. Completions are served from the local cache — no network request on every `<Tab>`.
## Development ## Shell Hook
`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
```sh ```sh
go test ./... # run all tests netssh hook install # auto-detects $SHELL
netssh hook install --shell bash
netssh hook install --shell zsh
netssh hook install --shell fish
```
Example output:
```
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
```
Fish alias file (`~/.config/fish/conf.d/netssh.fish`) is written automatically and sourced by fish on every shell start without any profile changes.
| 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` |
The install is idempotent — running it again changes nothing if the lines are already present.
Reload your profile after installation to activate immediately:
```sh
source ~/.bashrc # bash
source ~/.zshrc # zsh
source ~/.config/fish/config.fish # fish
```
### Alias file updates
The alias file is regenerated automatically — no manual action needed:
| Event | Aliases updated |
|-------|----------------|
| New shell session (`shell-init`) | Yes — from the current local cache |
| `netssh cache refresh` | Yes — after the full refresh completes |
| Background 24h auto-refresh | Yes — after the background refresh completes |
Aliases for newly added hosts appear in the next shell session after a `cache refresh` has run.
### Uninstall
```sh
netssh hook uninstall # auto-detects $SHELL
netssh hook uninstall --shell zsh
```
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.
### 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 |
|---------|--------------|
| SSH connect / TUI start | On the next `netssh` call after 24 h have elapsed |
| Shell hook (`shell-init`) | On the first new shell session after 24 h have elapsed |
Both 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
Renovate monitors the release workflow's actions, the Go version in `go.mod`, and
all direct and indirect Go module dependencies. Go dependency updates also run
`go mod tidy` to keep `go.mod` and `go.sum` consistent.
```sh
go test ./... # includes offline installer/update tests
go build ./... # build all packages go build ./... # build all packages
``` ```
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, SSH argument parser, config loading, and the setup wizard. The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, SSH argument parser, config loading, setup wizard, shortcut normalization, and shell hook install/uninstall.
## Disclaimer
This is a **vibe-coded** project: the entire codebase — architecture, implementation, tests, and docs — was generated by an AI assistant (Claude by Anthropic). No human has reviewed or audited the code. It works for the author's personal use case, but correctness and security are not guaranteed. Read the source before running it in sensitive environments.
## How it works ## How it works
1. `netssh` checks whether the first argument is a known subcommand (`configure`, `search`, `cache`, `completion`). If not, it enters SSH wrapper mode. 1. `netssh` checks whether the first argument is a known subcommand (`configure`, `search`, `cache`, `completion`). If not, it enters SSH wrapper mode.
2. On first run or when `netbox.url` is empty, the interactive setup wizard starts automatically. 2. On first run or when `netbox.url` is empty, the interactive setup wizard starts automatically.
3. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …). 3. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …).
4. It checks the local cache. If the entry exists and is within the TTL, it connects immediately. 4. It resolves the destination: first an exact cache lookup, then shortcut expansion (domain append, hyphen folding) against the cache, and finally a fresh NetBox query with the expanded name. If the cache entry is within the TTL, no network request is made.
5. Otherwise it queries NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/` in parallel), runs the result through the resolver chain, and caches the IP. 5. Otherwise it queries NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/` in parallel), runs the result through the resolver chain, and caches the IP.
6. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP. 6. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP.
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
type connectionTestHost struct {
ID int `json:"id"`
Name string `json:"name"`
PrimaryIP4 *struct {
Address string `json:"address"`
} `json:"primary_ip4,omitempty"`
}
func connectionTestServer(t *testing.T, devices, vms []connectionTestHost) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var hosts []connectionTestHost
switch r.URL.Path {
case "/api/dcim/devices/":
hosts = devices
case "/api/virtualization/virtual-machines/":
hosts = vms
case "/api/ipam/ip-addresses/":
fmt.Fprint(w, `{"count":0,"results":[]}`)
return
default:
http.NotFound(w, r)
return
}
query := strings.ToLower(r.URL.Query().Get("name__ic"))
matched := make([]connectionTestHost, 0)
for _, host := range hosts {
if strings.Contains(strings.ToLower(host.Name), query) {
matched = append(matched, host)
}
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 50
}
start, end := min(offset, len(matched)), min(offset+limit, len(matched))
json.NewEncoder(w).Encode(map[string]any{"count": len(matched), "results": matched[start:end]})
}))
t.Cleanup(srv.Close)
return srv
}
func TestFindSSHHostBeyondFirstPage(t *testing.T) {
var devices []connectionTestHost
for i := range 50 {
devices = append(devices, connectionTestHost{ID: i + 1, Name: fmt.Sprintf("web-extra-%02d", i)})
}
devices = append(devices, connectionTestHost{ID: 51, Name: "web"})
srv := connectionTestServer(t, devices, nil)
host, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", nil)
if err != nil || host.ID != 51 {
t.Fatalf("host beyond first page not resolved: %+v, %v", host, err)
}
}
func TestFindSSHHostDomainExpansion(t *testing.T) {
for _, domain := range []string{"example.com", ".example.com", " .example.com "} {
t.Run(domain, func(t *testing.T) {
srv := connectionTestServer(t, []connectionTestHost{{ID: 1, Name: "web.example.com"}}, nil)
host, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", []string{domain})
if err != nil || host.Name != "web.example.com" {
t.Fatalf("domain expansion failed: %+v, %v", host, err)
}
})
}
}
func TestFindSSHHostRejectsAmbiguity(t *testing.T) {
srv := connectionTestServer(t, []connectionTestHost{{ID: 1, Name: "web"}, {ID: 2, Name: "web.example.com"}}, []connectionTestHost{{ID: 3, Name: "web"}})
_, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", []string{"example.com"})
if err == nil || errors.Is(err, netbox.ErrHostNotFound) || !strings.Contains(err.Error(), "2 exact matches") {
t.Fatalf("ambiguous name accepted or expanded: %v", err)
}
}
func TestFindSSHHostDoesNotHideAPIErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer srv.Close()
_, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", []string{"example.com"})
if err == nil || errors.Is(err, netbox.ErrHostNotFound) {
t.Fatalf("API failure hidden as missing host: %v", err)
}
}
func TestFindSSHHostNotFound(t *testing.T) {
srv := connectionTestServer(t, nil, nil)
_, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "missing", []string{"example.com"})
if !errors.Is(err, netbox.ErrHostNotFound) {
t.Fatalf("want missing-host error, got %v", err)
}
}
func TestCacheRefreshHonorsResolverAndRemovesUnsafeEntries(t *testing.T) {
for _, tc := range []struct {
name, strategies string
wantCached bool
}{
{"management only", "[management_subnet]", false},
{"explicit primary fallback", "[management_subnet, primary_ip]", true},
} {
t.Run(tc.name, func(t *testing.T) {
host := connectionTestHost{ID: 1, Name: "web", PrimaryIP4: &struct {
Address string `json:"address"`
}{Address: "192.0.2.1/24"}}
srv := connectionTestServer(t, []connectionTestHost{host}, nil)
path := prepareRefreshTest(t, srv.URL, tc.strategies)
c := cache.New(path, 3600)
c.Upsert(cache.Entry{Name: "web", IP: "192.0.2.99", Kind: "device"})
if err := c.Save(); err != nil {
t.Fatal(err)
}
cmd := rootCmd()
cmd.SetArgs([]string{"cache", "refresh"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if err := c.Load(); err != nil {
t.Fatal(err)
}
got, fresh := c.Get("web")
if tc.wantCached {
if !fresh || got.IP != "192.0.2.1" {
t.Fatalf("explicit fallback failed: %+v", got)
}
} else if got.Name != "" {
t.Fatalf("unsafe primary IP retained: %+v", got)
}
})
}
}
func TestCacheRefreshSkipsAmbiguousNames(t *testing.T) {
host := connectionTestHost{ID: 1, Name: "web", PrimaryIP4: &struct {
Address string `json:"address"`
}{Address: "192.0.2.1/24"}}
vm := host
vm.ID = 2
srv := connectionTestServer(t, []connectionTestHost{host}, []connectionTestHost{vm})
path := prepareRefreshTest(t, srv.URL, "[primary_ip]")
c := cache.New(path, 3600)
c.Upsert(cache.Entry{Name: "web", IP: "192.0.2.99", Kind: "device"})
if err := c.Save(); err != nil {
t.Fatal(err)
}
cmd := rootCmd()
cmd.SetArgs([]string{"cache", "refresh"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if err := c.Load(); err != nil {
t.Fatal(err)
}
if len(c.All()) != 0 {
t.Fatalf("ambiguous host cached: %+v", c.All())
}
}
func prepareRefreshTest(t *testing.T, baseURL, strategies string) string {
t.Helper()
configDir := t.TempDir()
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", configDir)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
path := filepath.Join(t.TempDir(), "hosts.json")
t.Setenv("NETSSH_CACHE_PATH", path)
t.Setenv("NETSSH_NETBOX_URL", baseURL)
t.Setenv("NETSSH_NETBOX_TOKEN", "token")
t.Setenv("NETSSH_SHELL_ALIAS", "")
data := fmt.Sprintf("resolver:\n strategies: %s\n management_subnets: [10.0.0.0/8]\n", strategies)
if err := os.WriteFile(filepath.Join(configDir, "netssh.yaml"), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
return path
}
+369 -35
View File
@@ -2,22 +2,28 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"text/tabwriter" "text/tabwriter"
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache" "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/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"
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh" internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
) )
@@ -25,8 +31,12 @@ 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,
"hook": true,
"shell-init": true,
"completion": true, "completion": true,
"__complete": true, "__complete": true,
"help": true, "help": true,
@@ -87,16 +97,46 @@ func runSSHWrapper(args []string) {
args = append([]string{"-l", cfg.SSH.DefaultUser}, args...) args = append([]string{"-l", cfg.SSH.DefaultUser}, args...)
parsed.DestIdx += 2 parsed.DestIdx += 2
} }
// Inject the configured default port if none was given on the command line.
if cfg.SSH.DefaultPort > 0 && !internalssh.HasPortFlag(args) {
args = append([]string{"-p", strconv.Itoa(cfg.SSH.DefaultPort)}, args...)
parsed.DestIdx += 2
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
_ = c.Load() _ = c.Load()
maybeBackgroundRefresh(cfg, c)
// Cache hit with a fresh TTL — connect directly without querying NetBox. // Cache hit with a fresh TTL — connect directly without querying NetBox.
if entry, fresh := c.Get(parsed.Host); fresh { if entry, fresh := c.Get(parsed.Host); fresh {
c.MarkUsed(parsed.Host)
_ = c.Save()
connect(entry.IP, parsed, args) connect(entry.IP, parsed, args)
return return
} }
// Shortcut cache lookup: scan entries whose normalized name matches the input.
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
lookupHost := parsed.Host
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" {
normalize := shortcuts.MakeNormalizer(cfg.Shortcuts)
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
if err != nil {
fatalf("%v", err)
}
if found {
if fresh {
c.MarkUsed(entry.Name)
_ = c.Save()
connect(entry.IP, parsed, args)
return
}
// Stale shortcut match — use canonical name for NetBox re-fetch.
lookupHost = entry.Name
}
}
if cfg.NetBox.URL == "" { if cfg.NetBox.URL == "" {
fatalf("netbox.url is not configured (~/.config/netssh.yaml)") fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
} }
@@ -105,20 +145,9 @@ func runSSHWrapper(args []string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
entries, err := nbClient.Search(ctx, parsed.Host) target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains)
if err != nil { if err != nil {
fatalf("NetBox search failed: %v", err) fatalf("NetBox: %v", err)
}
var target *netbox.HostEntry
for i, e := range entries {
if strings.EqualFold(e.Name, parsed.Host) {
target = &entries[i]
break
}
}
if target == nil {
fatalf("host %q not found in NetBox", parsed.Host)
} }
chain, err := resolver.New(cfg.Resolver) chain, err := resolver.New(cfg.Resolver)
@@ -126,17 +155,38 @@ func runSSHWrapper(args []string) {
fatalf("resolver: %v", err) fatalf("resolver: %v", err)
} }
ip, err := chain.Resolve(ctx, target, nbClient) ip, err := chain.Resolve(ctx, &target, nbClient)
if err != nil { if err != nil {
fatalf("IP resolution for %q: %v", parsed.Host, err) fatalf("IP resolution for %q: %v", parsed.Host, err)
} }
c.Upsert(cache.Entry{Name: target.Name, IP: ip, Kind: target.Kind, Tags: target.Tags}) c.Upsert(cache.Entry{Name: target.Name, IP: ip, Kind: target.Kind, Tags: target.Tags})
c.MarkUsed(target.Name)
_ = c.Save() _ = c.Save()
connect(ip, parsed, args) connect(ip, parsed, args)
} }
// findSSHHost uses the paginated, ambiguity-checking lookup for connection
// targets. Only a genuine missing host permits trying configured suffixes.
func findSSHHost(ctx context.Context, client *netbox.Client, name string, domains []string) (netbox.HostEntry, error) {
target, err := client.FindHost(ctx, name, "")
if !errors.Is(err, netbox.ErrHostNotFound) || strings.Contains(name, ".") {
return target, err
}
for _, domain := range domains {
domain = strings.TrimPrefix(strings.TrimSpace(domain), ".")
if domain == "" {
continue
}
target, err = client.FindHost(ctx, name+"."+domain, "")
if !errors.Is(err, netbox.ErrHostNotFound) {
return target, err
}
}
return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name)
}
func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) { func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) {
newArgs := internalssh.ReplaceHost(originalArgs, parsed.DestIdx, ip) newArgs := internalssh.ReplaceHost(originalArgs, parsed.DestIdx, ip)
if err := internalssh.Exec(newArgs); err != nil { if err := internalssh.Exec(newArgs); err != nil {
@@ -150,14 +200,16 @@ func runTUI() {
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
_ = c.Load() _ = c.Load()
maybeBackgroundRefresh(cfg, c)
var nbClient *netbox.Client var nbClient *netbox.Client
if cfg.NetBox.URL != "" { if cfg.NetBox.URL != "" {
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) m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
p := tea.NewProgram(m, tea.WithAltScreen()) m.SetNotes(notes.New(cfg.NetBox.URL, nbClient))
p := tea.NewProgram(m)
final, err := p.Run() final, err := p.Run()
if err != nil { if err != nil {
fatalf("TUI: %v", err) fatalf("TUI: %v", err)
@@ -172,14 +224,55 @@ func runTUI() {
return return
} }
// Revalidate stale or live TUI results through the configured resolver chain.
if cached, fresh := c.Get(host.Name); fresh && cached.Kind == host.Kind {
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
if cfg.SSH.DefaultUser != "" {
sshArgs = append(sshArgs, "-l", cfg.SSH.DefaultUser) // User override from TUI edit mode takes priority, then config default.
user := host.User
if user == "" {
user = cfg.SSH.DefaultUser
} }
if user != "" {
sshArgs = append(sshArgs, "-l", user)
}
port := host.Port
if port == "" && cfg.SSH.DefaultPort > 0 {
port = strconv.Itoa(cfg.SSH.DefaultPort)
}
if port != "" {
sshArgs = append(sshArgs, "-p", port)
}
sshArgs = append(sshArgs, host.IP) sshArgs = append(sshArgs, host.IP)
c.MarkUsed(host.Name)
_ = c.Save()
if err := internalssh.Exec(sshArgs); err != nil { if err := internalssh.Exec(sshArgs); err != nil {
fatalf("%v", err) fatalf("%v", err)
} }
@@ -214,7 +307,7 @@ func rootCmd() *cobra.Command {
}, },
} }
root.AddCommand(configureCmd(), searchCmd(), cacheCmd()) 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()
@@ -265,8 +358,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
@@ -288,7 +380,17 @@ func completionInstallCmd(root *cobra.Command) *cobra.Command {
if err := os.WriteFile(file, script, 0o644); err != nil { if err := os.WriteFile(file, script, 0o644); err != nil {
return fmt.Errorf("writing %s: %w", file, err) return fmt.Errorf("writing %s: %w", file, err)
} }
cfg, err := config.Load()
if err != nil {
return err
}
if err := hook.SyncCommandCompletion(cfg.Shell.Alias); err != nil {
return err
}
fmt.Printf("Completion installed → %s\n%s\n", file, note) fmt.Printf("Completion installed → %s\n%s\n", file, note)
if cfg.Shell.Alias != "" {
fmt.Printf("Completion also enabled for alias %q.\n", cfg.Shell.Alias)
}
return nil return nil
}, },
} }
@@ -392,7 +494,8 @@ func cacheClearCmd() *cobra.Command {
} }
func cacheRefreshCmd() *cobra.Command { func cacheRefreshCmd() *cobra.Command {
return &cobra.Command{ var filterTag, filterKind string
cmd := &cobra.Command{
Use: "refresh", Use: "refresh",
Short: "Re-fetch all known hosts from NetBox", Short: "Re-fetch all known hosts from NetBox",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -405,11 +508,11 @@ func cacheRefreshCmd() *cobra.Command {
} }
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion) nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
// An empty query returns up to 50 entries per type. opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind}
entries, err := nbClient.Search(ctx, "") entries, err := nbClient.SearchAll(ctx, "", opts)
if err != nil { if err != nil {
return fmt.Errorf("NetBox: %w", err) return fmt.Errorf("NetBox: %w", err)
} }
@@ -417,28 +520,259 @@ 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
nameCounts := make(map[string]int)
for _, e := range entries {
nameCounts[strings.ToLower(e.Name)]++
}
for i := range entries { for i := range entries {
e := &entries[i] e := &entries[i]
ip := e.PrimaryIP4 if nameCounts[strings.ToLower(e.Name)] > 1 {
if ip == "" && chain != nil { c.Delete(e.Name)
resolved, err := chain.Resolve(ctx, e, nbClient) fmt.Fprintf(os.Stderr, "warning: skipping ambiguous hostname %q\n", e.Name)
if err == nil { continue
ip = resolved
} }
ip, err := chain.Resolve(ctx, e, nbClient)
if err != nil {
// Do not turn a failed configured strategy into a fresh
// primary-IP cache hit that bypasses the resolver.
if ctx.Err() != nil {
return ctx.Err()
}
c.Delete(e.Name)
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err)
continue
} }
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
} }
fmt.Printf("%d entries written to cache.\n", len(entries)) if filterTag == "" && filterKind == "" {
if err := c.SetRefreshed(); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
}
}
if aliasEntries := buildAliasEntries(c, *cfg); len(aliasEntries) > 0 {
if err := hook.WriteAliasFiles(aliasEntries); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err)
}
}
fmt.Printf("%d entries written to cache.\n", written)
return nil return nil
}, },
} }
cmd.Flags().StringVar(&filterTag, "tag", "", "Filter by NetBox tag slug (e.g. prod)")
cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm")
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 {
var shell string
cmd := &cobra.Command{
Use: "alias",
Short: "Print shell aliases for all cached hosts",
Long: `Print shell alias definitions for all cached hosts.
The alias name is the shortened form derived from the configured shortcuts
(domain suffixes stripped, hyphens optionally stripped).
Source the output in your shell profile:
bash/zsh: eval "$(netssh alias)"
fish: netssh alias --shell fish | source
Or use in a script:
source <(netssh alias)`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
if err := c.Load(); err != nil {
return err
}
if shell == "" {
shell = filepath.Base(os.Getenv("SHELL"))
}
entries := buildAliasEntries(c, *cfg)
if len(entries) == 0 {
fmt.Fprintln(os.Stderr, "netssh: cache is empty — run 'netssh cache refresh' first")
return nil
}
switch shell {
case "fish":
fmt.Printf("# netssh aliases (%d hosts) — source with: netssh alias --shell fish | source\n", len(entries))
for _, e := range entries {
fmt.Println(e.Definition("fish"))
}
default:
fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(entries))
for _, e := range entries {
fmt.Println(e.Definition("bash"))
}
}
return nil
},
}
cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)")
return cmd
}
// shellInitCmd is called at shell startup to update alias files and trigger a
// background cache refresh when stale. Intentionally silent — never disrupts the prompt.
func shellInitCmd() *cobra.Command {
return &cobra.Command{
Use: "shell-init",
Short: "Regenerate alias files and trigger a background cache refresh if stale",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return nil
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
_ = c.Load()
// Regenerate alias files from the current cache every shell start
// so aliases are always up-to-date even before a refresh completes.
_ = hook.WriteAliasFiles(buildAliasEntries(c, *cfg))
maybeBackgroundRefresh(cfg, c)
return nil
},
}
}
func hookCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "hook",
Short: "Manage shell hooks for automatic cache refresh at shell startup",
}
cmd.AddCommand(hookInstallCmd(), hookUninstallCmd())
return cmd
}
func hookInstallCmd() *cobra.Command {
var shell string
cmd := &cobra.Command{
Use: "install",
Short: "Add netssh shell-init to your shell profile",
Long: `Appends a single line to your shell profile that runs 'netssh shell-init'
on every new shell session. shell-init checks whether the cache is older
than 24 hours and, if so, starts a background refresh — no delay to your prompt.
After installation, reload your profile or open a new shell.`,
RunE: func(cmd *cobra.Command, args []string) error {
if shell == "" {
shell = filepath.Base(os.Getenv("SHELL"))
}
profile, err := hook.ProfilePath(shell)
if err != nil {
return err
}
installed, err := hook.Install(profile)
if err != nil {
return err
}
// For bash/zsh: also add the source line so aliases are loaded on startup.
// Fish uses conf.d auto-sourcing — no explicit source line needed.
if shell != "fish" {
if _, err := hook.InstallAliasesSource(profile); err != nil {
return err
}
}
if !installed {
fmt.Printf("Hook already installed in %s\n", profile)
} else {
fmt.Printf("Hook installed → %s\n%s\n", profile, hook.ReloadNote(profile))
}
// Generate the aliases file immediately so it is ready on the next shell start.
cfg, cfgErr := config.Load()
if cfgErr == nil && cfg.NetBox.URL != "" {
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
if _ = c.Load(); true {
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
if err := hook.WriteAliasFiles(entries); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err)
} else {
fmt.Printf("%d aliases written to %s\n", len(entries), hook.AliasesPath())
}
} else {
fmt.Println("Cache is empty — run 'netssh cache refresh' to populate aliases.")
}
}
}
return nil
},
}
cmd.Flags().StringVar(&shell, "shell", "", "Shell to install for (default: $SHELL). Supported: bash, zsh, fish")
return cmd
}
func hookUninstallCmd() *cobra.Command {
var shell string
cmd := &cobra.Command{
Use: "uninstall",
Short: "Remove netssh shell-init from your shell profile",
RunE: func(cmd *cobra.Command, args []string) error {
if shell == "" {
shell = filepath.Base(os.Getenv("SHELL"))
}
profile, err := hook.ProfilePath(shell)
if err != nil {
return err
}
removed, err := hook.Uninstall(profile)
if err != nil {
return err
}
if !removed {
fmt.Printf("No hook found in %s\n", profile)
return nil
}
fmt.Printf("Hook removed from %s\n", profile)
return nil
},
}
cmd.Flags().StringVar(&shell, "shell", "", "Shell to uninstall from (default: $SHELL). Supported: bash, zsh, fish")
return cmd
}
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
// has not been fully refreshed within the last 24 hours.
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
if cfg.NetBox.URL == "" {
return
}
if !c.NeedsRefresh(24 * time.Hour) {
return
}
self, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(self, "cache", "refresh")
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Stdin = nil
_ = cmd.Start() // fire and forget — becomes orphan after parent execs ssh
} }
func fatalf(format string, args ...any) { func fatalf(format string, args ...any) {
+93
View File
@@ -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
}
+76
View File
@@ -0,0 +1,76 @@
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("HOME", t.TempDir())
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)
}
}
+37 -33
View File
@@ -1,49 +1,53 @@
module git.zb-server.de/Sebi/ssh-netbox-wrapper module git.zb-server.de/Sebi/ssh-netbox-wrapper
go 1.26.3 go 1.27.3
require (
charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.1.0
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.6
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
golang.org/x/sys v0.49.0
)
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.9 // indirect
github.com/charmbracelet/huh v1.0.0 // indirect github.com/charmbracelet/x/exp/golden v0.1.0 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/charmbracelet/x/xpty v0.1.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/dustin/go-humanize v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.31 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/sahilm/fuzzy v0.1.3 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect github.com/stretchr/testify v1.12.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v1.2.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sync v0.24.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/text v0.43.0 // indirect
) )
+78 -61
View File
@@ -1,71 +1,85 @@
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f h1:D2JpB65eehsnkEwEG401dMBSNYtTDgvKXZZSl7ZCxWI=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/x/exp/golden v0.1.0 h1:qgUxGaHX4I8Fg077vD+54+qFmDWrgO19lTCQLblHWko=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/exp/golden v0.1.0/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/charmbracelet/x/xpty v0.1.4 h1:4jaW7u+8AHQMxesiVc+zUMsspu7GyDwtJO+gy/tFtW4=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/charmbracelet/x/xpty v0.1.4/go.mod h1:7t8P7BpPiolHJ1pLzz7/4ujDbD+sUxI9yA3CBOLOIcU=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -77,16 +91,19 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+218 -72
View File
@@ -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}"
# --- helpers -----------------------------------------------------------------
red() { printf '\033[31m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; } 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
die() { red "error: $*" >&2; exit 1; } # Re-running the one-liner updates the existing installation in its current path.
if [[ -z "${INSTALL_DIR:-}" ]]; then
need() { EXISTING=$(command -v "$BINARY" || true)
command -v "$1" &>/dev/null || die "'$1' is required but not installed" if [[ "$EXISTING" == /* && -f "$EXISTING" ]]; then
} INSTALL_DIR=$(dirname "$EXISTING")
else
# --- detect OS / arch -------------------------------------------------------- INSTALL_DIR=/usr/local/bin
fi
detect_os() { fi
case "$(uname -s)" in [[ "$INSTALL_DIR" == /* ]] || INSTALL_DIR="$PWD/$INSTALL_DIR"
Linux) echo linux ;; # Check sudo only when the destination needs elevated permissions, also before
Darwin) echo darwin ;; # downloading. For a new directory, inspect its nearest existing parent.
PRIVILEGE=()
INSTALL_PARENT=$INSTALL_DIR
while [[ ! -e "$INSTALL_PARENT" ]]; do INSTALL_PARENT=$(dirname "$INSTALL_PARENT"); done
[[ -d "$INSTALL_PARENT" ]] || die "installation path is not a directory: $INSTALL_PARENT"
if [[ ! -w "$INSTALL_PARENT" || ! -x "$INSTALL_PARENT" ]]; then
need sudo
PRIVILEGE=(sudo)
fi
case "$(uname -s)" in
Linux) PLATFORM_OS=linux ;;
Darwin) PLATFORM_OS=darwin ;;
*) die "unsupported OS: $(uname -s)" ;; *) die "unsupported OS: $(uname -s)" ;;
esac esac
} case "$(uname -m)" in
x86_64|amd64) PLATFORM_ARCH=amd64 ;;
detect_arch() { aarch64|arm64) PLATFORM_ARCH=arm64 ;;
case "$(uname -m)" in
x86_64|amd64) echo amd64 ;;
aarch64|arm64) echo arm64 ;;
*) die "unsupported architecture: $(uname -m)" ;; *) die "unsupported architecture: $(uname -m)" ;;
esac esac
} printf 'netssh install / update\n'
info "platform : $PLATFORM_OS/$PLATFORM_ARCH"
# --- main --------------------------------------------------------------------
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."
+351
View File
@@ -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")
}
}
+189 -8
View File
@@ -2,11 +2,17 @@ package cache
import ( import (
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"golang.org/x/sys/unix"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
) )
type Entry struct { type Entry struct {
@@ -15,13 +21,25 @@ type Entry struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
CachedAt time.Time `json:"cached_at"` CachedAt time.Time `json:"cached_at"`
LastUsed time.Time `json:"last_used,omitempty"`
} }
type changeKind uint8
const (
upsertChange changeKind = iota
deleteChange
usageChange
)
type Cache struct { type Cache struct {
mu sync.RWMutex mu sync.RWMutex
dirty map[string]changeKind
cleared bool
entries map[string]Entry entries map[string]Entry
path string path string
ttl time.Duration ttl time.Duration
refreshStamp string // path to last_refresh timestamp file
} }
type diskFormat struct { type diskFormat struct {
@@ -29,13 +47,51 @@ type diskFormat struct {
} }
func New(path string, ttlSeconds int) *Cache { func New(path string, ttlSeconds int) *Cache {
stamp := ""
if path != "" {
stamp = filepath.Join(filepath.Dir(path), "last_refresh")
}
return &Cache{ return &Cache{
entries: make(map[string]Entry), entries: make(map[string]Entry),
dirty: make(map[string]changeKind),
path: path, path: path,
ttl: time.Duration(ttlSeconds) * time.Second, ttl: time.Duration(ttlSeconds) * time.Second,
refreshStamp: stamp,
} }
} }
// NeedsRefresh reports whether the last full cache refresh (via `cache refresh`)
// is older than d, or has never happened. Always returns false when no path is set.
func (c *Cache) NeedsRefresh(d time.Duration) bool {
if c.refreshStamp == "" {
return false
}
data, err := os.ReadFile(c.refreshStamp)
if err != nil {
return true // file missing → never refreshed
}
var t time.Time
if err := t.UnmarshalText(data); err != nil {
return true
}
return time.Since(t) >= d
}
// SetRefreshed records the current time as the last successful full refresh.
func (c *Cache) SetRefreshed() error {
if c.refreshStamp == "" {
return nil
}
data, err := time.Now().MarshalText()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(c.refreshStamp), 0o755); err != nil {
return err
}
return os.WriteFile(c.refreshStamp, data, 0o644)
}
func (c *Cache) Load() error { func (c *Cache) Load() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -53,6 +109,8 @@ func (c *Cache) Load() error {
return err return err
} }
c.dirty = make(map[string]changeKind)
c.cleared = false
c.entries = make(map[string]Entry, len(df.Entries)) c.entries = make(map[string]Entry, len(df.Entries))
for _, e := range df.Entries { for _, e := range df.Entries {
c.entries[e.Name] = e c.entries[e.Name] = e
@@ -60,32 +118,137 @@ func (c *Cache) Load() error {
return nil return nil
} }
// Save merges only local changes under a process-wide file lock. Atomic rename
// alone prevents torn JSON but cannot prevent lost updates from another process.
func (c *Cache) Save() error { func (c *Cache) Save() error {
c.mu.RLock() c.mu.Lock()
df := diskFormat{Entries: make([]Entry, 0, len(c.entries))} defer c.mu.Unlock()
for _, e := range c.entries {
df.Entries = append(df.Entries, e)
}
c.mu.RUnlock()
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
return err return err
} }
lock, err := os.OpenFile(c.path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return err
}
defer lock.Close()
if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX); err != nil {
return err
}
defer unix.Flock(int(lock.Fd()), unix.LOCK_UN)
merged := make(map[string]Entry)
if !c.cleared {
data, err := os.ReadFile(c.path)
if err != nil && !os.IsNotExist(err) {
return err
}
if err == nil {
var df diskFormat
if err := json.Unmarshal(data, &df); err != nil {
return err
}
for _, e := range df.Entries {
merged[e.Name] = e
}
}
}
for name, change := range c.dirty {
if change == deleteChange {
delete(merged, name)
continue
}
e := c.entries[name]
old, exists := merged[name]
if change == usageChange && !exists {
// A connection finishing after a refresh or clear must not
// restore an entry that the other process removed.
continue
}
if exists {
lastUsed := e.LastUsed
if change == usageChange || old.CachedAt.After(e.CachedAt) {
e = old
}
if old.LastUsed.After(lastUsed) {
lastUsed = old.LastUsed
}
e.LastUsed = lastUsed
}
merged[name] = e
}
df := diskFormat{Entries: make([]Entry, 0, len(merged))}
for _, e := range merged {
df.Entries = append(df.Entries, e)
}
sort.Slice(df.Entries, func(i, j int) bool { return df.Entries[i].Name < df.Entries[j].Name })
data, err := json.MarshalIndent(df, "", " ") data, err := json.MarshalIndent(df, "", " ")
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(c.path, data, 0o644) if err := storage.WriteFile(c.path, data, 0o600); err != nil {
return err
}
c.entries = merged
c.dirty = make(map[string]changeKind)
c.cleared = false
return nil
} }
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.dirty[e.Name] = upsertChange
c.mu.Unlock() c.mu.Unlock()
} }
// Delete removes an entry when a refresh can no longer resolve it safely.
func (c *Cache) Delete(name string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, name)
c.dirty[name] = deleteChange
}
// MarkUsed records the current time as LastUsed for the named entry.
// It is a no-op if the entry does not exist.
func (c *Cache) MarkUsed(name string) {
c.mu.Lock()
defer c.mu.Unlock()
if e, ok := c.entries[name]; ok {
e.LastUsed = time.Now()
c.entries[name] = e
if _, changed := c.dirty[name]; !changed {
c.dirty[name] = usageChange
}
}
}
// RecentlyUsed returns the n most recently used entries, sorted by LastUsed desc.
// Entries that have never been used (LastUsed zero) are excluded.
// If n <= 0, all used entries are returned.
func (c *Cache) RecentlyUsed(n int) []Entry {
c.mu.RLock()
defer c.mu.RUnlock()
var used []Entry
for _, e := range c.entries {
if !e.LastUsed.IsZero() {
used = append(used, e)
}
}
sort.Slice(used, func(i, j int) bool {
return used[i].LastUsed.After(used[j].LastUsed)
})
if n > 0 && len(used) > n {
used = used[:n]
}
return used
}
// Search returns all entries whose name starts with prefix (case-insensitive). // Search returns all entries whose name starts with prefix (case-insensitive).
// TTL is intentionally ignored — this is used for shell completion. // TTL is intentionally ignored — this is used for shell completion.
func (c *Cache) Search(prefix string) []Entry { func (c *Cache) Search(prefix string) []Entry {
@@ -102,6 +265,22 @@ func (c *Cache) Search(prefix string) []Entry {
return out return out
} }
// GetByShortcut rejects collisions instead of choosing a random map entry.
func (c *Cache) GetByShortcut(shortcut string, normalize func(string) string) (entry Entry, found bool, fresh bool, err error) {
c.mu.RLock()
defer c.mu.RUnlock()
norm := normalize(shortcut)
for _, e := range c.entries {
if normalize(e.Name) == norm {
if found {
return Entry{}, false, false, fmt.Errorf("ambiguous shortcut %q; use a full hostname", shortcut)
}
entry, found = e, true
}
}
return entry, found, found && c.ttl > 0 && time.Since(entry.CachedAt) < c.ttl, nil
}
// Get returns an entry and reports whether it is still within the TTL. // Get returns an entry and reports whether it is still within the TTL.
func (c *Cache) Get(name string) (entry Entry, fresh bool) { func (c *Cache) Get(name string) (entry Entry, fresh bool) {
c.mu.RLock() c.mu.RLock()
@@ -120,6 +299,8 @@ func (c *Cache) Get(name string) (entry Entry, fresh bool) {
func (c *Cache) Clear() { func (c *Cache) Clear() {
c.mu.Lock() c.mu.Lock()
c.entries = make(map[string]Entry) c.entries = make(map[string]Entry)
c.dirty = make(map[string]changeKind)
c.cleared = true
c.mu.Unlock() c.mu.Unlock()
} }
+250
View File
@@ -220,6 +220,244 @@ func TestSave_ProducesValidJSON(t *testing.T) {
} }
} }
func TestMarkUsed_SetsLastUsed(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
before := time.Now()
c.MarkUsed("host")
e, _ := c.Get("host")
if e.LastUsed.Before(before) {
t.Error("LastUsed should be set to current time by MarkUsed")
}
}
func TestMarkUsed_NoopForMissingEntry(t *testing.T) {
c := New("", 60)
c.MarkUsed("nonexistent") // should not panic
}
func TestRecentlyUsed_ReturnsTopN(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "a", IP: "1.1.1.1", Kind: "device"})
c.Upsert(Entry{Name: "b", IP: "2.2.2.2", Kind: "device"})
c.Upsert(Entry{Name: "c", IP: "3.3.3.3", Kind: "device"})
c.MarkUsed("c")
time.Sleep(time.Millisecond)
c.MarkUsed("a")
results := c.RecentlyUsed(2)
if len(results) != 2 {
t.Fatalf("RecentlyUsed(2): got %d results, want 2", len(results))
}
if results[0].Name != "a" {
t.Errorf("first result: got %q, want %q", results[0].Name, "a")
}
if results[1].Name != "c" {
t.Errorf("second result: got %q, want %q", results[1].Name, "c")
}
}
func TestRecentlyUsed_ExcludesNeverUsed(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "used", IP: "1.1.1.1", Kind: "device"})
c.Upsert(Entry{Name: "unused", IP: "2.2.2.2", Kind: "device"})
c.MarkUsed("used")
results := c.RecentlyUsed(10)
if len(results) != 1 || results[0].Name != "used" {
t.Errorf("RecentlyUsed should exclude entries with zero LastUsed, got %v", results)
}
}
func TestRecentlyUsed_EmptyCache(t *testing.T) {
c := New("", 60)
if results := c.RecentlyUsed(10); len(results) != 0 {
t.Errorf("RecentlyUsed on empty cache: got %d, want 0", len(results))
}
}
func TestMarkUsed_RoundtripViaSave(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
c := New(path, 3600)
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
c.MarkUsed("host")
if err := c.Save(); err != nil {
t.Fatalf("Save: %v", err)
}
c2 := New(path, 3600)
if err := c2.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
results := c2.RecentlyUsed(1)
if len(results) != 1 || results[0].Name != "host" {
t.Errorf("LastUsed not persisted: %v", results)
}
}
// --- GetByShortcut tests ---
func TestGetByShortcut_MatchFresh(t *testing.T) {
c := New("", 3600)
c.Upsert(Entry{Name: "web01.example.com", IP: "10.0.0.1", Kind: "device"})
normalize := func(s string) string {
// strip .example.com
if len(s) > len(".example.com") && s[len(s)-len(".example.com"):] == ".example.com" {
s = s[:len(s)-len(".example.com")]
}
return s
}
e, found, fresh, _ := c.GetByShortcut("web01", normalize)
if !found {
t.Fatal("expected entry to be found")
}
if !fresh {
t.Error("entry just inserted should be fresh")
}
if e.IP != "10.0.0.1" {
t.Errorf("IP: got %q, want %q", e.IP, "10.0.0.1")
}
}
func TestGetByShortcut_MatchStale(t *testing.T) {
c := New("", 1) // 1 second TTL
stale := Entry{Name: "web01.example.com", IP: "10.0.0.1", Kind: "device", CachedAt: time.Now().Add(-2 * time.Second)}
c.mu.Lock()
c.entries["web01.example.com"] = stale
c.mu.Unlock()
normalize := func(s string) string {
if len(s) > len(".example.com") && s[len(s)-len(".example.com"):] == ".example.com" {
s = s[:len(s)-len(".example.com")]
}
return s
}
_, found, fresh, _ := c.GetByShortcut("web01", normalize)
if !found {
t.Fatal("expected entry to be found even when stale")
}
if fresh {
t.Error("entry older than TTL should not be fresh")
}
}
func TestGetByShortcut_NotFound(t *testing.T) {
c := New("", 3600)
c.Upsert(Entry{Name: "db01.example.com", IP: "10.0.0.2", Kind: "device"})
_, found, _, _ := c.GetByShortcut("web01", func(s string) string { return s })
if found {
t.Error("should not find an entry that does not match the shortcut")
}
}
func TestGetByShortcut_EmptyCache(t *testing.T) {
c := New("", 3600)
_, found, _, _ := c.GetByShortcut("web01", func(s string) string { return s })
if found {
t.Error("empty cache should return found=false")
}
}
func TestGetByShortcut_MultiDomain(t *testing.T) {
c := New("", 3600)
// Entry uses second domain (.example.de)
c.Upsert(Entry{Name: "web01.example.de", IP: "10.0.0.5", Kind: "vm"})
normalize := func(s string) string {
for _, suffix := range []string{".example.com", ".example.de"} {
if len(s) > len(suffix) && s[len(s)-len(suffix):] == suffix {
return s[:len(s)-len(suffix)]
}
}
return s
}
e, found, _, _ := c.GetByShortcut("web01", normalize)
if !found {
t.Fatal("should match entry with second configured domain")
}
if e.IP != "10.0.0.5" {
t.Errorf("IP: got %q, want %q", e.IP, "10.0.0.5")
}
}
// --- NeedsRefresh / SetRefreshed tests ---
// These tests require NeedsRefresh(time.Duration) bool and SetRefreshed() error
// to be implemented on *Cache. The refreshStamp field (path to the timestamp file)
// must be set before calling these methods.
func TestNeedsRefresh_NeverRefreshed(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if !c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh should return true when last_refresh file does not exist")
}
}
func TestNeedsRefresh_JustRefreshed(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if err := c.SetRefreshed(); err != nil {
t.Fatalf("SetRefreshed: %v", err)
}
if c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh should return false immediately after SetRefreshed")
}
}
func TestNeedsRefresh_Stale(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
// Write a timestamp 25 hours ago
oldTime := time.Now().Add(-25 * time.Hour).Format(time.RFC3339)
if err := os.WriteFile(stampPath, []byte(oldTime), 0o644); err != nil {
t.Fatalf("writing stamp: %v", err)
}
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if !c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh should return true when last_refresh is older than duration")
}
}
func TestSetRefreshed_Roundtrip(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if err := c.SetRefreshed(); err != nil {
t.Fatalf("SetRefreshed: %v", err)
}
// Just refreshed: 24h window → not stale
if c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh(24h) should be false right after SetRefreshed")
}
// Zero duration: everything is stale
if !c.NeedsRefresh(0) {
t.Error("NeedsRefresh(0) should always return true")
}
}
// tempFile writes content to a temp file and returns its path. // tempFile writes content to a temp file and returns its path.
func tempFile(t *testing.T, content []byte) string { func tempFile(t *testing.T, content []byte) string {
t.Helper() t.Helper()
@@ -233,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)
}
}
+182
View File
@@ -0,0 +1,182 @@
package cache
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestShortcutRejectsAmbiguousTargets(t *testing.T) {
c := New("", 3600)
c.Upsert(Entry{Name: "web.prod.example", IP: "10.0.0.1"})
c.Upsert(Entry{Name: "web.dev.example", IP: "10.0.0.2"})
normalize := func(s string) string { return strings.SplitN(s, ".", 2)[0] }
for range 100 {
entry, found, fresh, err := c.GetByShortcut("web", normalize)
if err == nil || found || fresh || entry.Name != "" {
t.Fatalf("ambiguous shortcut returned target: %+v, %v, %v, %v", entry, found, fresh, err)
}
}
}
func TestSaveMergesIndependentlyLoadedCaches(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts.json")
a, b := New(path, 3600), New(path, 3600)
for _, c := range []*Cache{a, b} {
if err := c.Load(); err != nil {
t.Fatal(err)
}
}
a.Upsert(Entry{Name: "a", IP: "10.0.0.1"})
b.Upsert(Entry{Name: "b", IP: "10.0.0.2"})
for _, c := range []*Cache{a, b} {
if err := c.Save(); err != nil {
t.Fatal(err)
}
}
if err := a.Load(); err != nil {
t.Fatal(err)
}
if len(a.All()) != 2 {
t.Fatalf("lost an update: %+v", a.All())
}
// Saving a stale usage update must retain newer IPs and both usage times.
if err := b.Load(); err != nil {
t.Fatal(err)
}
b.MarkUsed("a")
used, _ := b.Get("a")
a.Upsert(Entry{Name: "a", IP: "10.0.0.3"})
if err := a.Save(); err != nil {
t.Fatal(err)
}
if err := b.Save(); err != nil {
t.Fatal(err)
}
got, _ := b.Get("a")
if got.IP != "10.0.0.3" || !got.LastUsed.Equal(used.LastUsed) {
t.Fatalf("lost refreshed IP or usage: %+v", got)
}
b.Delete("a")
if err := b.Save(); err != nil {
t.Fatal(err)
}
if err := a.Load(); err != nil {
t.Fatal(err)
}
if got, _ := a.Get("a"); got.Name != "" {
t.Fatalf("deleted entry survived: %+v", got)
}
a.Clear()
if err := a.Save(); err != nil {
t.Fatal(err)
}
if err := b.Load(); err != nil {
t.Fatal(err)
}
if len(b.All()) != 0 {
t.Fatal("clear merged old entries back in")
}
}
func TestSaveAcrossProcesses(t *testing.T) {
if path := os.Getenv("NETSSH_TEST_CACHE_WRITER_PATH"); path != "" {
c := New(path, 3600)
if err := c.Load(); err != nil {
t.Fatal(err)
}
c.Upsert(Entry{Name: os.Getenv("NETSSH_TEST_CACHE_WRITER_NAME"), IP: "10.0.0.1"})
if err := c.Save(); err != nil {
t.Fatal(err)
}
return
}
path := filepath.Join(t.TempDir(), "hosts.json")
const writers = 8
cmds := make([]*exec.Cmd, writers)
for i := range cmds {
cmd := exec.Command(os.Args[0], "-test.run=^TestSaveAcrossProcesses$")
cmd.Env = append(os.Environ(), "NETSSH_TEST_CACHE_WRITER_PATH="+path, fmt.Sprintf("NETSSH_TEST_CACHE_WRITER_NAME=host-%d", i))
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
cmds[i] = cmd
}
for _, cmd := range cmds {
if err := cmd.Wait(); err != nil {
t.Error(err)
}
}
c := New(path, 3600)
if err := c.Load(); err != nil {
t.Fatal(err)
}
if len(c.All()) != writers {
t.Fatalf("lost concurrent updates: got %d, want %d", len(c.All()), writers)
}
}
func TestSavePreservesNewerUsageFromDisk(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts.json")
a := New(path, 3600)
a.Upsert(Entry{Name: "web", IP: "10.0.0.1", LastUsed: time.Now()})
if err := a.Save(); err != nil {
t.Fatal(err)
}
b := New(path, 3600)
if err := b.Load(); err != nil {
t.Fatal(err)
}
a.MarkUsed("web")
used, _ := a.Get("web")
if err := a.Save(); err != nil {
t.Fatal(err)
}
b.Upsert(Entry{Name: "web", IP: "10.0.0.2"})
if err := b.Save(); err != nil {
t.Fatal(err)
}
got, _ := b.Get("web")
if got.IP != "10.0.0.2" || !got.LastUsed.Equal(used.LastUsed) {
t.Fatalf("lost data: %+v", got)
}
}
func TestUsageSaveDoesNotRestoreRemovedHosts(t *testing.T) {
for _, clear := range []bool{false, true} {
t.Run(fmt.Sprintf("clear=%v", clear), func(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts.json")
a := New(path, 3600)
a.Upsert(Entry{Name: "web", IP: "10.0.0.1"})
if err := a.Save(); err != nil {
t.Fatal(err)
}
b := New(path, 3600)
if err := b.Load(); err != nil {
t.Fatal(err)
}
b.MarkUsed("web")
if clear {
a.Clear()
} else {
a.Delete("web")
}
if err := a.Save(); err != nil {
t.Fatal(err)
}
if err := b.Save(); err != nil {
t.Fatal(err)
}
if len(b.All()) != 0 {
t.Fatalf("usage update restored removed entry: %+v", b.All())
}
})
}
}
+38
View File
@@ -4,16 +4,23 @@ 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"`
SSH SSHConfig `mapstructure:"ssh"` SSH SSHConfig `mapstructure:"ssh"`
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
}
type ShellConfig struct {
Alias string `mapstructure:"alias"`
} }
type NetBoxConfig struct { type NetBoxConfig struct {
@@ -35,6 +42,12 @@ type CacheConfig struct {
type SSHConfig struct { type SSHConfig struct {
DefaultUser string `mapstructure:"default_user"` DefaultUser string `mapstructure:"default_user"`
DefaultPort int `mapstructure:"default_port"`
}
type ShortcutsConfig struct {
Domains []string `mapstructure:"domains"`
StripHyphens bool `mapstructure:"strip_hyphens"`
} }
// Path returns the canonical config file path. // Path returns the canonical config file path.
@@ -62,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 {
@@ -75,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
@@ -93,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
}
+34
View File
@@ -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)
}
}
}
+145
View File
@@ -0,0 +1,145 @@
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
alias := ""
for _, entry := range entries {
if entry.Host == "" {
alias = entry.Name
break
}
}
return SyncCommandCompletion(alias)
}
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")
}
+229
View File
@@ -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)
}
}
+116
View File
@@ -0,0 +1,116 @@
package hook
import (
"fmt"
"os"
"path/filepath"
"strings"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
)
const completionMarker = "# netssh command alias completion — generated automatically"
// SyncCommandCompletion installs lazy loaders for the configured command alias.
// The loaders obtain the canonical completion from the current netssh binary,
// so aliases configured by the wizard work without a separate completion install.
func SyncCommandCompletion(alias string) error {
if err := config.ValidateCommandAlias(alias); err != nil {
return err
}
statePath := filepath.Join(filepath.Dir(AliasesPath()), "completion_alias")
data, err := os.ReadFile(statePath)
if err != nil && !os.IsNotExist(err) {
return err
}
previous := strings.TrimSpace(string(data))
if err := config.ValidateCommandAlias(previous); err != nil {
return fmt.Errorf("invalid completion alias state: %w", err)
}
if alias == "" && previous == "" {
return nil
}
for _, shell := range []string{"bash", "zsh", "fish"} {
if previous != "" && previous != alias {
path := commandCompletionPath(shell, previous)
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return err
}
// Only delete files owned by netssh; preserve any user replacements.
if strings.HasPrefix(string(data), completionMarker+"\n") || strings.HasPrefix(string(data), "#compdef "+previous+"\n"+completionMarker+"\n") {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
}
}
if alias == "" {
continue
}
if shell == "fish" {
if _, err := os.Stat(FishConfigDir()); os.IsNotExist(err) {
continue
} else if err != nil {
return err
}
}
path := commandCompletionPath(shell, alias)
script := commandCompletionScript(shell, alias)
existing, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return err
}
if string(existing) == script {
continue
}
if err := storage.WriteFile(path, []byte(script), 0o644); err != nil {
return fmt.Errorf("writing %s alias completion: %w", shell, err)
}
}
if alias == "" {
if err := os.Remove(statePath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
return storage.WriteFile(statePath, []byte(alias+"\n"), 0o600)
}
func commandCompletionPath(shell, alias string) string {
switch shell {
case "bash":
return filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", alias)
case "zsh":
return filepath.Join(os.Getenv("HOME"), ".zfunc", "_"+alias)
default:
return filepath.Join(FishConfigDir(), "completions", alias+".fish")
}
}
func commandCompletionScript(shell, alias string) string {
switch shell {
case "bash":
return fmt.Sprintf(`%s
source <(netssh completion bash)
if [[ $(type -t compopt) == builtin ]]; then
complete -o default -F __start_netssh %s
else
complete -o default -o nospace -F __start_netssh %s
fi
`, completionMarker, alias, alias)
case "zsh":
return fmt.Sprintf(`#compdef %s
%s
source <(netssh completion zsh)
compdef _netssh %s
_netssh "$@"
`, alias, completionMarker, alias)
default:
return fmt.Sprintf(`%s
netssh completion fish | source
complete --command %s --wraps netssh
`, completionMarker, alias)
}
}
+171
View File
@@ -0,0 +1,171 @@
package hook_test
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
"github.com/spf13/cobra"
)
func completionEnvironment(t *testing.T) map[string]string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache"))
if err := os.MkdirAll(hook.FishConfigDir(), 0o755); err != nil {
t.Fatal(err)
}
return map[string]string{
"bash": filepath.Join(home, ".local", "share", "bash-completion", "completions", "j"),
"zsh": filepath.Join(home, ".zfunc", "_j"),
"fish": filepath.Join(hook.FishConfigDir(), "completions", "j.fish"),
}
}
func TestCommandCompletionTracksAliasChanges(t *testing.T) {
paths := completionEnvironment(t)
if err := hook.WriteAliasFiles([]hook.AliasEntry{{Name: "j"}}); err != nil {
t.Fatal(err)
}
for _, path := range paths {
if _, err := os.Stat(path); err != nil {
t.Fatal(err)
}
}
// A manually replaced completion file must survive removal of the old alias.
custom := "# user-owned completion\n"
if err := os.WriteFile(paths["bash"], []byte(custom), 0o644); err != nil {
t.Fatal(err)
}
if err := hook.WriteAliasFiles([]hook.AliasEntry{{Name: "k"}}); err != nil {
t.Fatal(err)
}
for shell, path := range paths {
if shell == "bash" {
data, err := os.ReadFile(path)
if err != nil || string(data) != custom {
t.Fatal("user completion was removed")
}
} else if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("stale %s completion remains: %v", shell, err)
}
}
if err := hook.WriteAliasFiles(nil); err != nil {
t.Fatal(err)
}
for _, path := range []string{
filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", "k"),
filepath.Join(os.Getenv("HOME"), ".zfunc", "_k"),
filepath.Join(hook.FishConfigDir(), "completions", "k.fish"),
} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("disabled completion remains: %s: %v", path, err)
}
}
}
func TestCommandAliasCompletesServerNamesAcrossShells(t *testing.T) {
for _, shell := range []string{"bash", "zsh", "fish"} {
t.Run(shell, func(t *testing.T) {
executable, err := exec.LookPath(shell)
if err != nil {
t.Skipf("%s unavailable", shell)
}
paths := completionEnvironment(t)
if err := hook.SyncCommandCompletion("j"); err != nil {
t.Fatal(err)
}
fixtures := t.TempDir()
root := &cobra.Command{Use: "netssh"}
var canonical bytes.Buffer
switch shell {
case "bash":
err = root.GenBashCompletionV2(&canonical, true)
case "zsh":
err = root.GenZshCompletion(&canonical)
case "fish":
err = root.GenFishCompletion(&canonical, true)
}
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(fixtures, shell), canonical.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
// Exercise Cobra's actual generated shell code with a deterministic backend.
backend := `#!/bin/bash
case "$1" in
completion) cat "$COMPLETION_FIXTURES/$2" ;;
__complete|__completeNoDesc) printf 'server01\n:4\n' ;;
*) exit 1 ;;
esac
`
if err := os.WriteFile(filepath.Join(fixtures, "netssh"), []byte(backend), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("COMPLETION_FIXTURES", fixtures)
t.Setenv("ALIAS_COMPLETION", paths[shell])
t.Setenv("PATH", fixtures+string(os.PathListSeparator)+os.Getenv("PATH"))
var script string
var args []string
switch shell {
case "bash":
library := ""
for _, path := range []string{"/usr/share/bash-completion/bash_completion", "/etc/bash_completion"} {
if _, err := os.Stat(path); err == nil {
library = path
break
}
}
if library == "" {
t.Skip("bash-completion unavailable")
}
t.Setenv("BASH_COMPLETION_LIBRARY", library)
args = []string{"--noprofile", "--norc", "-c"}
script = `source "$BASH_COMPLETION_LIBRARY"
shopt -s expand_aliases
alias j=netssh
_completion_loader j
COMP_WORDS=(j ser)
COMP_CWORD=1
COMP_LINE='j ser'
COMP_POINT=5
COMP_TYPE=9
COMP_KEY=9
__start_netssh j ser j
printf '%s\n' "${COMPREPLY[@]}"
`
case "zsh":
args = []string{"-f", "-c"}
script = `fpath=("$HOME/.zfunc" $fpath)
autoload -Uz compinit
compinit -u -D
alias j=netssh
autoload -Uz _j
# Capture matches without requiring an interactive ZLE completion widget.
_describe() { print -rl -- "${completions[@]}"; }
words=(j ser)
CURRENT=2
_j
`
case "fish":
args = []string{"--no-config", "-c"}
script = `alias j netssh
set -p fish_complete_path "$XDG_CONFIG_HOME/fish/completions"
complete --do-complete 'j ser'
`
}
cmd := exec.Command(executable, append(args, script)...)
out, err := cmd.CombinedOutput()
if err != nil || !strings.Contains(string(out), "server01") {
t.Fatalf("%s completion: %v\n%s", shell, err, out)
}
})
}
}
+170
View File
@@ -0,0 +1,170 @@
package hook
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
)
const (
// Marker is the unique string used to detect an existing hook line.
Marker = "netssh shell-init"
BlockStart = "# >>> netssh >>>"
BlockEnd = "# <<< netssh <<<"
// Line is the full line written into the shell profile.
Line = "netssh shell-init # netssh cache auto-refresh"
// AliasesMarker is the unique string used to detect the aliases source line.
AliasesMarker = "netssh/aliases.sh"
// AliasesSourceLine is the bash/zsh line that sources the generated aliases file.
AliasesSourceLine = "source ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases"
)
// ProfilePath returns the canonical shell profile path for the given shell name.
func ProfilePath(shell string) (string, error) {
switch shell {
case "bash":
return filepath.Join(os.Getenv("HOME"), ".bashrc"), nil
case "zsh":
dir := os.Getenv("ZDOTDIR")
if dir == "" {
dir = os.Getenv("HOME")
}
return filepath.Join(dir, ".zshrc"), nil
case "fish":
return filepath.Join(FishConfigDir(), "config.fish"), nil
default:
return "", fmt.Errorf("unsupported shell %q — supported: bash, zsh, fish", shell)
}
}
// ReloadNote returns the shell-specific hint for reloading the profile.
func ReloadNote(profilePath string) string {
return "Reload with: source " + profilePath
}
// IsInstalled reports whether the hook marker is present in the profile file.
// Returns false if the file does not exist or cannot be read.
func IsInstalled(profilePath string) bool {
data, err := os.ReadFile(profilePath)
if err != nil {
return false
}
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.
// Returns (true, nil) when newly installed, (false, nil) when already present.
// Creates the profile file (and any parent directories) if they do not exist.
func Install(profilePath string) (installed bool, err error) {
if IsInstalled(profilePath) {
return false, nil
}
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
return false, fmt.Errorf("creating directory: %w", err)
}
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, "\n%s\n", Line); err != nil {
return false, err
}
return true, nil
}
// InstallAliasesSource appends the aliases source line to a bash/zsh profile.
// This is a no-op for fish (which uses conf.d auto-sourcing instead).
// Returns (true, nil) when newly added, (false, nil) when already present.
func InstallAliasesSource(profilePath string) (installed bool, err error) {
data, err := os.ReadFile(profilePath)
if err != nil && !os.IsNotExist(err) {
return false, fmt.Errorf("reading profile: %w", err)
}
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) {
data, err := os.ReadFile(profilePath)
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("reading profile: %w", err)
}
var kept []string
inside := false
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
}
kept = append(kept, line)
}
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")
target, err := filepath.EvalSymlinks(profilePath)
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 true, nil
}
+290
View File
@@ -0,0 +1,290 @@
package hook_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
)
// --- ProfilePath ---
func TestProfilePath_Bash(t *testing.T) {
orig := os.Getenv("HOME")
os.Setenv("HOME", t.TempDir())
defer os.Setenv("HOME", orig)
p, err := hook.ProfilePath("bash")
if err != nil {
t.Fatalf("ProfilePath(bash): %v", err)
}
if !strings.HasSuffix(p, ".bashrc") {
t.Errorf("bash profile should end with .bashrc, got %q", p)
}
}
func TestProfilePath_Zsh(t *testing.T) {
orig := os.Getenv("HOME")
os.Setenv("HOME", t.TempDir())
defer os.Setenv("HOME", orig)
p, err := hook.ProfilePath("zsh")
if err != nil {
t.Fatalf("ProfilePath(zsh): %v", err)
}
if !strings.HasSuffix(p, ".zshrc") {
t.Errorf("zsh profile should end with .zshrc, got %q", p)
}
}
func TestProfilePath_Fish(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, err := hook.ProfilePath("fish")
if err != nil {
t.Fatalf("ProfilePath(fish): %v", err)
}
if !strings.HasSuffix(p, "config.fish") {
t.Errorf("fish profile should end with config.fish, got %q", p)
}
}
func TestProfilePath_UnknownShell(t *testing.T) {
if _, err := hook.ProfilePath("ksh"); err == nil {
t.Error("unknown shell should return an error")
}
}
// --- IsInstalled ---
func TestIsInstalled_Present(t *testing.T) {
profile := writeProfile(t, hook.Line+"\n")
if !hook.IsInstalled(profile) {
t.Error("IsInstalled should return true when marker is present")
}
}
func TestIsInstalled_Absent(t *testing.T) {
profile := writeProfile(t, "export PATH=$PATH\n")
if hook.IsInstalled(profile) {
t.Error("IsInstalled should return false when marker is absent")
}
}
func TestIsInstalled_MissingFile(t *testing.T) {
if hook.IsInstalled("/nonexistent/path/.bashrc") {
t.Error("IsInstalled should return false for missing file")
}
}
// --- Install ---
func TestInstall_Fresh(t *testing.T) {
profile := filepath.Join(t.TempDir(), ".bashrc")
installed, err := hook.Install(profile)
if err != nil {
t.Fatalf("Install: %v", err)
}
if !installed {
t.Error("should report installed=true on fresh install")
}
if !hook.IsInstalled(profile) {
t.Error("profile should contain the hook line after Install")
}
}
func TestInstall_Idempotent(t *testing.T) {
profile := filepath.Join(t.TempDir(), ".bashrc")
hook.Install(profile)
installed, err := hook.Install(profile)
if err != nil {
t.Fatalf("second Install: %v", err)
}
if installed {
t.Error("second Install should report installed=false")
}
data, _ := os.ReadFile(profile)
if count := strings.Count(string(data), hook.Marker); count != 1 {
t.Errorf("hook line should appear exactly once, got %d", count)
}
}
func TestInstall_CreatesProfileAndParentDirs(t *testing.T) {
profile := filepath.Join(t.TempDir(), "nested", "dir", ".zshrc")
if _, err := hook.Install(profile); err != nil {
t.Fatalf("Install should create missing directories: %v", err)
}
if _, err := os.Stat(profile); err != nil {
t.Error("profile file should have been created")
}
}
func TestInstall_PreservesExistingContent(t *testing.T) {
profile := writeProfile(t, "export PATH=$PATH:/usr/local/bin\n")
hook.Install(profile)
data, _ := os.ReadFile(profile)
content := string(data)
if !strings.Contains(content, "export PATH") {
t.Error("Install should not remove existing content")
}
if !strings.Contains(content, hook.Marker) {
t.Error("hook line should be appended")
}
}
func TestInstall_AppendedAtEnd(t *testing.T) {
profile := writeProfile(t, "existing line\n")
hook.Install(profile)
data, _ := os.ReadFile(profile)
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
last := lines[len(lines)-1]
if last != hook.Line {
t.Errorf("hook line should be the last line, got %q", last)
}
}
// --- Uninstall ---
func TestUninstall_Removes(t *testing.T) {
profile := writeProfile(t, "export PATH=$PATH\n"+hook.Line+"\nexport FOO=bar\n")
removed, err := hook.Uninstall(profile)
if err != nil {
t.Fatalf("Uninstall: %v", err)
}
if !removed {
t.Error("should report removed=true")
}
if hook.IsInstalled(profile) {
t.Error("hook line should have been removed")
}
}
func TestUninstall_PreservesOtherContent(t *testing.T) {
profile := writeProfile(t, "export FOO=bar\n"+hook.Line+"\nexport BAZ=qux\n")
hook.Uninstall(profile)
data, _ := os.ReadFile(profile)
content := string(data)
if !strings.Contains(content, "export FOO=bar") {
t.Error("content before hook should be preserved")
}
if !strings.Contains(content, "export BAZ=qux") {
t.Error("content after hook should be preserved")
}
}
func TestUninstall_NotPresent(t *testing.T) {
profile := writeProfile(t, "export PATH=$PATH\n")
removed, err := hook.Uninstall(profile)
if err != nil {
t.Fatalf("Uninstall: %v", err)
}
if removed {
t.Error("should report removed=false when hook was not present")
}
}
func TestUninstall_MissingFile(t *testing.T) {
removed, err := hook.Uninstall("/nonexistent/.bashrc")
if err != nil {
t.Errorf("Uninstall on missing file should not error: %v", err)
}
if removed {
t.Error("should report removed=false for missing file")
}
}
func TestUninstall_CollapsesExtraBlankLines(t *testing.T) {
// blank line before hook + hook + blank line after = three consecutive newlines after removal
profile := writeProfile(t, "line1\n\n"+hook.Line+"\n\nline2\n")
hook.Uninstall(profile)
data, _ := os.ReadFile(profile)
if strings.Contains(string(data), "\n\n\n") {
t.Error("three consecutive blank lines should be collapsed after uninstall")
}
}
// --- Roundtrip ---
func TestInstallUninstall_Roundtrip(t *testing.T) {
profile := writeProfile(t, "existing content\n")
hook.Install(profile)
if !hook.IsInstalled(profile) {
t.Fatal("should be installed after Install")
}
hook.Uninstall(profile)
if hook.IsInstalled(profile) {
t.Fatal("should not be installed after Uninstall")
}
data, _ := os.ReadFile(profile)
if !strings.Contains(string(data), "existing content") {
t.Error("original content should survive install+uninstall cycle")
}
}
// --- ReloadNote ---
func TestReloadNote_ContainsPath(t *testing.T) {
note := hook.ReloadNote("/home/user/.bashrc")
if !strings.Contains(note, "/home/user/.bashrc") {
t.Errorf("ReloadNote should contain the profile path, got %q", note)
}
}
// --- helpers ---
func writeProfile(t *testing.T, content string) string {
t.Helper()
f := filepath.Join(t.TempDir(), "profile")
if err := os.WriteFile(f, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
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)
}
}
+222 -18
View File
@@ -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,24 +31,30 @@ 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 devices and VMs in parallel and merges the results. // Search queries up to 50 devices and VMs in parallel and merges the results.
func (c *Client) Search(ctx context.Context, query string) ([]HostEntry, error) { // Use SearchOptions to restrict by kind or tag.
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
errs []error errs []error
wg sync.WaitGroup wg sync.WaitGroup
started int
) )
wg.Add(2) if opts.Kind != "vm" {
started++
wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
devices, err := c.searchDevices(ctx, query) devices, err := c.searchDevices(ctx, query, opts.Tag)
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
if err != nil { if err != nil {
@@ -53,10 +63,14 @@ func (c *Client) Search(ctx context.Context, query string) ([]HostEntry, error)
} }
results = append(results, devices...) results = append(results, devices...)
}() }()
}
if opts.Kind != "device" {
started++
wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
vms, err := c.searchVMs(ctx, query) vms, err := c.searchVMs(ctx, query, opts.Tag)
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
if err != nil { if err != nil {
@@ -65,13 +79,67 @@ func (c *Client) Search(ctx context.Context, query string) ([]HostEntry, error)
} }
results = append(results, vms...) results = append(results, vms...)
}() }()
}
wg.Wait() wg.Wait()
if len(errs) == 2 { if len(errs) == started {
if started == 1 {
return nil, errs[0]
}
return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1]) return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1])
} }
return results, nil
}
// SearchAll paginates through all matching devices and VMs, fetching every page.
// Intended for cache refresh; use Search for interactive queries.
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 (
mu sync.Mutex
results []HostEntry
errs []error
wg sync.WaitGroup
)
if opts.Kind != "vm" {
wg.Add(1)
go func() {
defer wg.Done()
devices, err := c.fetchAllDevices(ctx, query, opts.Tag)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, fmt.Errorf("devices: %w", err))
return
}
results = append(results, devices...)
}()
}
if opts.Kind != "device" {
wg.Add(1)
go func() {
defer wg.Done()
vms, err := c.fetchAllVMs(ctx, query, opts.Tag)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, fmt.Errorf("vms: %w", err))
return
}
results = append(results, vms...)
}()
}
wg.Wait()
if len(errs) > 0 {
return nil, errors.Join(errs...)
}
return results, nil return results, nil
} }
@@ -114,8 +182,11 @@ func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]s
return ips, nil return ips, nil
} }
func (c *Client) searchDevices(ctx context.Context, query string) ([]HostEntry, error) { func (c *Client) searchDevices(ctx context.Context, query, tag string) ([]HostEntry, error) {
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query)) apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
var resp netboxListResponse[netboxDevice] var resp netboxListResponse[netboxDevice]
if err := c.get(ctx, apiURL, &resp); err != nil { if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err return nil, err
@@ -127,8 +198,11 @@ func (c *Client) searchDevices(ctx context.Context, query string) ([]HostEntry,
return entries, nil return entries, nil
} }
func (c *Client) searchVMs(ctx context.Context, query string) ([]HostEntry, error) { func (c *Client) searchVMs(ctx context.Context, query, tag string) ([]HostEntry, error) {
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query)) apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
var resp netboxListResponse[netboxVM] var resp netboxListResponse[netboxVM]
if err := c.get(ctx, apiURL, &resp); err != nil { if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err return nil, err
@@ -140,6 +214,52 @@ func (c *Client) searchVMs(ctx context.Context, query string) ([]HostEntry, erro
return entries, nil return entries, nil
} }
func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]HostEntry, error) {
const pageSize = 50
var all []HostEntry
for offset := 0; ; offset += pageSize {
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=%d&offset=%d",
c.baseURL, url.QueryEscape(query), pageSize, offset)
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
var resp netboxListResponse[netboxDevice]
if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err
}
for _, d := range resp.Results {
all = append(all, deviceToEntry(d))
}
if len(resp.Results) == 0 || len(all) >= resp.Count {
break
}
}
return all, nil
}
func (c *Client) fetchAllVMs(ctx context.Context, query, tag string) ([]HostEntry, error) {
const pageSize = 50
var all []HostEntry
for offset := 0; ; offset += pageSize {
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=%d&offset=%d",
c.baseURL, url.QueryEscape(query), pageSize, offset)
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
var resp netboxListResponse[netboxVM]
if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err
}
for _, v := range resp.Results {
all = append(all, vmToEntry(v))
}
if len(resp.Results) == 0 || len(all) >= resp.Count {
break
}
}
return all, nil
}
// TokenVersion returns 2 for NetBox v2 tokens (nbt_ prefix) or 1 for legacy tokens. // TokenVersion returns 2 for NetBox v2 tokens (nbt_ prefix) or 1 for legacy tokens.
func TokenVersion(token string) int { func TokenVersion(token string) int {
if strings.HasPrefix(token, "nbt_") { if strings.HasPrefix(token, "nbt_") {
@@ -149,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()
@@ -167,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 {
@@ -215,3 +350,72 @@ func stripPrefix(cidr string) string {
} }
return cidr return cidr
} }
// ErrHostNotFound distinguishes a missing exact match from API failures or ambiguity.
var ErrHostNotFound = errors.New("host not found")
// 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) == 0 {
return HostEntry{}, fmt.Errorf("%w: %q", ErrHostNotFound, name)
}
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
}
+162 -15
View File
@@ -60,7 +60,7 @@ func TestSearch_ReturnsBothDevicesAndVMs(t *testing.T) {
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token", 0) c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "") results, err := c.Search(context.Background(), "", SearchOptions{})
if err != nil { if err != nil {
t.Fatalf("Search: %v", err) t.Fatalf("Search: %v", err)
} }
@@ -89,7 +89,7 @@ func TestSearch_MapsKindCorrectly(t *testing.T) {
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token", 0) c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "") results, _ := c.Search(context.Background(), "", SearchOptions{})
for _, r := range results { for _, r := range results {
switch r.Name { switch r.Name {
@@ -115,7 +115,7 @@ func TestSearch_StripsPrefixFromPrimaryIP(t *testing.T) {
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token", 0) c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "host") results, _ := c.Search(context.Background(), "host", SearchOptions{})
if len(results) == 0 { if len(results) == 0 {
t.Fatal("expected at least one result") t.Fatal("expected at least one result")
} }
@@ -140,7 +140,7 @@ func TestSearch_TagsAreMapped(t *testing.T) {
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token", 0) c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "") results, _ := c.Search(context.Background(), "", SearchOptions{})
if len(results[0].Tags) != 2 { if len(results[0].Tags) != 2 {
t.Errorf("tags: got %v, want [prod mgmt]", results[0].Tags) t.Errorf("tags: got %v, want [prod mgmt]", results[0].Tags)
} }
@@ -161,7 +161,7 @@ func TestSearch_PartialFailure_ReturnsAvailableResults(t *testing.T) {
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token", 0) c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "") results, err := c.Search(context.Background(), "", SearchOptions{})
if err != nil { if err != nil {
t.Fatalf("partial failure should not return error, got: %v", err) t.Fatalf("partial failure should not return error, got: %v", err)
} }
@@ -179,7 +179,7 @@ func TestSearch_BothFail_ReturnsError(t *testing.T) {
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token", 0) c := NewClient(srv.URL, "token", 0)
_, err := c.Search(context.Background(), "") _, err := c.Search(context.Background(), "", SearchOptions{})
if err == nil { if err == nil {
t.Error("both endpoints failing should return an error") t.Error("both endpoints failing should return an error")
} }
@@ -289,7 +289,7 @@ func Test403_V1Token_HintsUpgrade(t *testing.T) {
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "legacytoken", 1) c := NewClient(srv.URL, "legacytoken", 1)
_, err := c.Search(context.Background(), "host") _, err := c.Search(context.Background(), "host", SearchOptions{})
if err == nil { if err == nil {
t.Fatal("expected error on 403") t.Fatal("expected error on 403")
} }
@@ -305,7 +305,7 @@ func Test403_V2Token_NoV1Hint(t *testing.T) {
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "nbt_secret", 2) c := NewClient(srv.URL, "nbt_secret", 2)
_, err := c.Search(context.Background(), "host") _, err := c.Search(context.Background(), "host", SearchOptions{})
if err == nil { if err == nil {
t.Fatal("expected error on 403") t.Fatal("expected error on 403")
} }
@@ -318,21 +318,108 @@ 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"} {
t.Run(token, func(t *testing.T) {
want := "Token " + token
if strings.HasPrefix(token, "nbt_") {
want = "Bearer " + token
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization") if got := r.Header.Get("Authorization"); got != want {
t.Errorf("Authorization: got %q, want %q", got, want)
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
b, _ := json.Marshal(deviceListResponse()) b, _ := json.Marshal(deviceListResponse())
w.Write(b) w.Write(b)
})) }))
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, token, 0)
if _, err := c.Search(context.Background(), "", SearchOptions{}); err != nil {
t.Fatal(err)
}
})
}
}
c := NewClient(srv.URL, "nbt_mytoken", 2) func TestSearchAll_PaginatesResults(t *testing.T) {
c.Search(context.Background(), "") //nolint:errcheck // Simulate a NetBox endpoint with 3 total devices split across 2 pages.
// count=3 throughout; first page has 2 results, second has 1.
callCount := 0
mux := http.NewServeMux()
mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) {
callCount++
offset := r.URL.Query().Get("offset")
w.Header().Set("Content-Type", "application/json")
var resp netboxListResponse[netboxDevice]
switch offset {
case "", "0":
resp = netboxListResponse[netboxDevice]{
Count: 3,
Results: []netboxDevice{{ID: 1, Name: "d-01"}, {ID: 2, Name: "d-02"}},
}
default:
resp = netboxListResponse[netboxDevice]{
Count: 3,
Results: []netboxDevice{{ID: 3, Name: "d-03"}},
}
}
b, _ := json.Marshal(resp)
w.Write(b)
})
mux.HandleFunc("/api/virtualization/virtual-machines/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
b, _ := json.Marshal(vmListResponse())
w.Write(b)
})
srv := httptest.NewServer(mux)
defer srv.Close()
want := "Token nbt_mytoken" c := NewClient(srv.URL, "token", 0)
if gotAuth != want { results, err := c.SearchAll(context.Background(), "", SearchOptions{})
t.Errorf("Authorization header: got %q, want %q", gotAuth, want) if err != nil {
t.Fatalf("SearchAll: %v", err)
}
if len(results) != 3 {
t.Errorf("SearchAll: got %d results, want 3", len(results))
}
if callCount < 2 {
t.Errorf("expected at least 2 device API calls for pagination, got %d", callCount)
}
}
func TestSearch_KindFilterDeviceOnly(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/dcim/devices/": deviceListResponse(
netboxDevice{ID: 1, Name: "sw-01"},
),
})
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "", SearchOptions{Kind: "device"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(results) != 1 || results[0].Name != "sw-01" {
t.Errorf("expected 1 device result, got %v", results)
}
}
func TestSearch_KindFilterVMOnly(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/virtualization/virtual-machines/": vmListResponse(
netboxVM{ID: 1, Name: "vm-01"},
),
})
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "", SearchOptions{Kind: "vm"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(results) != 1 || results[0].Name != "vm-01" {
t.Errorf("expected 1 vm result, got %v", results)
} }
} }
@@ -352,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")
}
}
+7
View File
@@ -1,5 +1,12 @@
package netbox package netbox
// SearchOptions filters a Search or SearchAll query.
// Zero value means no filtering (return all kinds, no tag filter).
type SearchOptions struct {
Tag string // filter by tag slug; empty = no filter
Kind string // "device" | "vm" | "" (both)
}
// HostEntry is a unified model for both devices and virtual machines from NetBox. // HostEntry is a unified model for both devices and virtual machines from NetBox.
type HostEntry struct { type HostEntry struct {
ID int ID int
+71
View File
@@ -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)
}
+52
View File
@@ -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")
}
}
+163 -15
View File
@@ -8,10 +8,13 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/charmbracelet/huh" "charm.land/huh/v2"
"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.
@@ -20,13 +23,20 @@ func RunWizard(cfg *config.Config) error {
url := cfg.NetBox.URL url := cfg.NetBox.URL
token := cfg.NetBox.Token token := cfg.NetBox.Token
defaultUser := cfg.SSH.DefaultUser defaultUser := cfg.SSH.DefaultUser
strategies := cfg.Resolver.Strategies defaultPort := ""
if cfg.SSH.DefaultPort > 0 {
defaultPort = strconv.Itoa(cfg.SSH.DefaultPort)
}
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ") subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
interfaceName := cfg.Resolver.InterfaceName interfaceName := cfg.Resolver.InterfaceName
cacheTTL := strconv.Itoa(cfg.Cache.TTL) cacheTTL := strconv.Itoa(cfg.Cache.TTL)
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
stripHyphens := cfg.Shortcuts.StripHyphens
commandAlias := cfg.Shell.Alias
if len(strategies) == 0 { if strategiesRaw == "" {
strategies = []string{"primary_ip"} strategiesRaw = "primary_ip"
} }
if cacheTTL == "0" { if cacheTTL == "0" {
cacheTTL = "3600" cacheTTL = "3600"
@@ -62,18 +72,30 @@ func RunWizard(cfg *config.Config) error {
Title("Default SSH user"). Title("Default SSH user").
Description("Leave empty to use your system user ($USER)."). Description("Leave empty to use your system user ($USER).").
Value(&defaultUser), Value(&defaultUser),
huh.NewInput().
Title("Default SSH port").
Description("Leave empty to use the standard port (22).").
Placeholder("22").
Value(&defaultPort).
Validate(func(s string) error {
if strings.TrimSpace(s) == "" {
return nil
}
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 1 || n > 65535 {
return errors.New("must be a port number between 1 and 65535")
}
return nil
}),
).Title("SSH defaults"), ).Title("SSH defaults"),
huh.NewGroup( huh.NewGroup(
huh.NewMultiSelect[string](). huh.NewInput().
Title("Resolver strategies"). Title("Resolver strategies").
Description("Order matters: first match wins."). Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name").
Options( Placeholder("primary_ip, management_subnet").
huh.NewOption("primary_ip — NetBox primary IPv4/IPv6", "primary_ip"), Value(&strategiesRaw).
huh.NewOption("management_subnet — first IP inside a subnet", "management_subnet"), Validate(validateStrategies),
huh.NewOption("interface_name — IP on a named interface", "interface_name"),
).
Value(&strategies),
huh.NewInput(). huh.NewInput().
Title("Management subnets"). Title("Management subnets").
Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active."). Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active.").
@@ -93,6 +115,24 @@ func RunWizard(cfg *config.Config) error {
return nil return nil
}), }),
).Title("Resolver & cache"), ).Title("Resolver & cache"),
huh.NewGroup(
huh.NewInput().
Title("Domain suffixes").
Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'.").
Placeholder(".example.com").
Value(&shortcutDomains),
huh.NewConfirm().
Title("Strip hyphens").
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
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"),
) )
if err := form.Run(); err != nil { if err := form.Run(); err != nil {
@@ -103,14 +143,20 @@ func RunWizard(cfg *config.Config) error {
return err return err
} }
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")
} }
ttl, _ := strconv.Atoi(cacheTTL) ttl, _ := strconv.Atoi(cacheTTL)
port := 0
if p, err := strconv.Atoi(strings.TrimSpace(defaultPort)); err == nil {
port = p
}
var subnetList []string var subnetList []string
for _, s := range strings.Split(subnets, ",") { for _, s := range strings.Split(subnets, ",") {
if s = strings.TrimSpace(s); s != "" { if s = strings.TrimSpace(s); s != "" {
@@ -118,7 +164,18 @@ func RunWizard(cfg *config.Config) error {
} }
} }
var domainList []string
for _, s := range strings.Split(shortcutDomains, ",") {
if s = strings.TrimSpace(s); s != "" {
if !strings.HasPrefix(s, ".") {
s = "." + s
}
domainList = append(domainList, s)
}
}
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),
@@ -126,6 +183,7 @@ func RunWizard(cfg *config.Config) error {
}, },
SSH: config.SSHConfig{ SSH: config.SSHConfig{
DefaultUser: strings.TrimSpace(defaultUser), DefaultUser: strings.TrimSpace(defaultUser),
DefaultPort: port,
}, },
Resolver: config.ResolverConfig{ Resolver: config.ResolverConfig{
Strategies: strategies, Strategies: strategies,
@@ -134,13 +192,80 @@ func RunWizard(cfg *config.Config) error {
}, },
Cache: config.CacheConfig{ Cache: config.CacheConfig{
TTL: ttl, TTL: ttl,
Path: cfg.Cache.Path,
},
Shortcuts: config.ShortcutsConfig{
Domains: domainList,
StripHyphens: stripHyphens,
}, },
} }
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{
"primary_ip": true,
"management_subnet": true,
"interface_name": true,
}
func validateStrategies(s string) error {
if strings.TrimSpace(s) == "" {
return errors.New("at least one strategy is required")
}
for _, name := range parseStrategies(s) {
if !knownStrategies[name] {
return fmt.Errorf("unknown strategy %q — available: primary_ip, management_subnet, interface_name", name)
}
}
return nil
}
func parseStrategies(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
if name := strings.TrimSpace(part); name != "" {
out = append(out, name)
}
}
return out
} }
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)
@@ -169,13 +294,36 @@ 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 != "" {
fmt.Fprintf(&b, " default_user: %q\n", cfg.SSH.DefaultUser) fmt.Fprintf(&b, " default_user: %q\n", cfg.SSH.DefaultUser)
} }
if cfg.SSH.DefaultPort > 0 {
fmt.Fprintf(&b, " default_port: %d\n", cfg.SSH.DefaultPort)
}
if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens {
b.WriteString("\nshortcuts:\n")
if len(cfg.Shortcuts.Domains) > 0 {
b.WriteString(" domains:\n")
for _, d := range cfg.Shortcuts.Domains {
fmt.Fprintf(&b, " - %s\n", d)
}
}
if cfg.Shortcuts.StripHyphens {
b.WriteString(" strip_hyphens: true\n")
}
}
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)
} }
+224
View File
@@ -116,6 +116,210 @@ func TestSave_CreatesConfigDir(t *testing.T) {
} }
} }
func TestSave_DefaultPort(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)
cfg := config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
SSH: config.SSHConfig{DefaultPort: 2222},
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
if !strings.Contains(string(data), "default_port: 2222") {
t.Errorf("expected default_port: 2222 in config, got:\n%s", string(data))
}
}
func TestSave_Shortcuts_WritesSection(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)
cfg := config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
Shortcuts: config.ShortcutsConfig{
Domains: []string{".example.com"},
StripHyphens: true,
},
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
content := string(data)
for _, want := range []string{"shortcuts:", "domains:", ".example.com", "strip_hyphens: true"} {
if !strings.Contains(content, want) {
t.Errorf("expected %q in config, got:\n%s", want, content)
}
}
}
func TestSave_OmitsDefaultPortWhenZero(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)
cfg := config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
SSH: config.SSHConfig{DefaultPort: 0},
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
if strings.Contains(string(data), "default_port") {
t.Errorf("default_port should be omitted when zero, got:\n%s", string(data))
}
}
func TestSave_OmitsShortcutsWhenEmpty(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)
cfg := config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
Shortcuts: config.ShortcutsConfig{}, // empty
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
if strings.Contains(string(data), "shortcuts:") {
t.Errorf("shortcuts section should be omitted when empty, got:\n%s", string(data))
}
}
func TestSave_Roundtrip_WithNewFields(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)
original := config.Config{
NetBox: config.NetBoxConfig{
URL: "https://netbox.example.com",
Token: "nbt_test",
TokenVersion: 2,
},
SSH: config.SSHConfig{
DefaultUser: "admin",
DefaultPort: 2222,
},
Resolver: config.ResolverConfig{
Strategies: []string{"primary_ip"},
},
Cache: config.CacheConfig{TTL: 3600},
Shortcuts: config.ShortcutsConfig{
Domains: []string{".example.com", ".example.de"},
StripHyphens: true,
},
}
if err := save(original); err != nil {
t.Fatalf("save: %v", err)
}
loaded, err := config.Load()
if err != nil {
t.Fatalf("Load after save: %v", err)
}
if loaded.SSH.DefaultPort != original.SSH.DefaultPort {
t.Errorf("DefaultPort: got %d, want %d", loaded.SSH.DefaultPort, original.SSH.DefaultPort)
}
if len(loaded.Shortcuts.Domains) != len(original.Shortcuts.Domains) {
t.Errorf("Shortcuts.Domains length: got %d, want %d", len(loaded.Shortcuts.Domains), len(original.Shortcuts.Domains))
} else {
for i, d := range original.Shortcuts.Domains {
if loaded.Shortcuts.Domains[i] != d {
t.Errorf("Shortcuts.Domains[%d]: got %q, want %q", i, loaded.Shortcuts.Domains[i], d)
}
}
}
if loaded.Shortcuts.StripHyphens != original.Shortcuts.StripHyphens {
t.Errorf("StripHyphens: got %v, want %v", loaded.Shortcuts.StripHyphens, original.Shortcuts.StripHyphens)
}
}
func TestParseStrategies(t *testing.T) {
tests := []struct {
in string
want []string
}{
{"primary_ip", []string{"primary_ip"}},
{"management_subnet, primary_ip", []string{"management_subnet", "primary_ip"}},
{"primary_ip,management_subnet,interface_name", []string{"primary_ip", "management_subnet", "interface_name"}},
{" primary_ip , management_subnet ", []string{"primary_ip", "management_subnet"}},
{"", nil},
{" , ", nil},
}
for _, tt := range tests {
got := parseStrategies(tt.in)
if len(got) != len(tt.want) {
t.Errorf("parseStrategies(%q): got %v, want %v", tt.in, got, tt.want)
continue
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("parseStrategies(%q)[%d]: got %q, want %q", tt.in, i, got[i], tt.want[i])
}
}
}
}
func TestParseStrategies_PreservesOrder(t *testing.T) {
got := parseStrategies("interface_name, management_subnet, primary_ip")
want := []string{"interface_name", "management_subnet", "primary_ip"}
for i, s := range got {
if s != want[i] {
t.Errorf("order not preserved at [%d]: got %q, want %q", i, s, want[i])
}
}
}
func TestValidateStrategies_Valid(t *testing.T) {
cases := []string{
"primary_ip",
"management_subnet, primary_ip",
"interface_name, management_subnet, primary_ip",
}
for _, c := range cases {
if err := validateStrategies(c); err != nil {
t.Errorf("validateStrategies(%q) should be valid, got: %v", c, err)
}
}
}
func TestValidateStrategies_Invalid(t *testing.T) {
cases := []string{
"",
"unknown_strategy",
"primary_ip, typo",
}
for _, c := range cases {
if err := validateStrategies(c); err == nil {
t.Errorf("validateStrategies(%q) should return an error", c)
}
}
}
func TestSave_RoundtripViaLoad(t *testing.T) { func TestSave_RoundtripViaLoad(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME") orig := os.Getenv("XDG_CONFIG_HOME")
@@ -162,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)
}
}
+42
View File
@@ -0,0 +1,42 @@
package shortcuts
import (
"strings"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
// Normalize strips configured domain suffixes and optionally hyphens from a hostname.
// The result is lowercased for case-insensitive comparison.
func Normalize(name string, cfg config.ShortcutsConfig) string {
s := strings.ToLower(name)
for _, domain := range cfg.Domains {
suffix := strings.ToLower(domain)
if !strings.HasPrefix(suffix, ".") {
suffix = "." + suffix
}
if strings.HasSuffix(s, suffix) {
s = s[:len(s)-len(suffix)]
break
}
}
if cfg.StripHyphens {
s = strings.ReplaceAll(s, "-", "")
}
return s
}
// MakeNormalizer returns a closure bound to cfg, suitable for Cache.GetByShortcut.
func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string {
return func(name string) string {
return Normalize(name, cfg)
}
}
// AliasName returns a shell-safe alias name for the given host.
// It normalizes the name (strips configured domains and optionally hyphens) then
// replaces any remaining dots with underscores so the result is a valid identifier.
func AliasName(name string, cfg config.ShortcutsConfig) string {
s := Normalize(name, cfg)
return strings.ReplaceAll(s, ".", "_")
}
+80
View File
@@ -0,0 +1,80 @@
package shortcuts_test
import (
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
)
func TestNormalize(t *testing.T) {
cfg := config.ShortcutsConfig{
Domains: []string{".example.com", ".example.de"},
StripHyphens: true,
}
cases := []struct {
input string
want string
}{
{"fsn1-web01.example.com", "fsn1web01"},
{"fsn1-web01.example.de", "fsn1web01"},
{"FSN1-WEB01.EXAMPLE.COM", "fsn1web01"},
{"fsn1-web01", "fsn1web01"},
{"web01.example.com", "web01"},
{"web01.other.com", "web01.other.com"}, // unknown domain not stripped
}
for _, c := range cases {
got := shortcuts.Normalize(c.input, cfg)
if got != c.want {
t.Errorf("Normalize(%q) = %q, want %q", c.input, got, c.want)
}
}
}
func TestNormalizeNoHyphenStrip(t *testing.T) {
cfg := config.ShortcutsConfig{
Domains: []string{".example.com"},
StripHyphens: false,
}
got := shortcuts.Normalize("fsn1-web01.example.com", cfg)
want := "fsn1-web01"
if got != want {
t.Errorf("Normalize = %q, want %q", got, want)
}
}
func TestAliasName_WithDomainAndHyphens(t *testing.T) {
cfg := config.ShortcutsConfig{
Domains: []string{".example.com"},
StripHyphens: true,
}
got := shortcuts.AliasName("fsn1-web01.example.com", cfg)
want := "fsn1web01"
if got != want {
t.Errorf("AliasName = %q, want %q", got, want)
}
}
func TestAliasName_DotReplacement(t *testing.T) {
cfg := config.ShortcutsConfig{
Domains: []string{".example.com"},
}
// "web01.other.com" — domain not stripped, remaining dots → underscores
got := shortcuts.AliasName("web01.other.com", cfg)
want := "web01_other_com"
if got != want {
t.Errorf("AliasName = %q, want %q", got, want)
}
}
func TestAliasName_NoConfig(t *testing.T) {
cfg := config.ShortcutsConfig{}
got := shortcuts.AliasName("web01.example.com", cfg)
want := "web01_example_com"
if got != want {
t.Errorf("AliasName = %q, want %q", got, want)
}
}
+41 -18
View File
@@ -4,7 +4,7 @@ import "strings"
// flagsWithArg lists all SSH flags that consume the following argument. // flagsWithArg lists all SSH flags that consume the following argument.
var flagsWithArg = map[byte]bool{ var flagsWithArg = map[byte]bool{
'b': true, 'c': true, 'D': true, 'E': true, 'e': true, 'B': true, 'P': true, 'b': true, 'c': true, 'D': true, 'E': true, 'e': true,
'F': true, 'I': true, 'i': true, 'J': true, 'L': true, 'F': true, 'I': true, 'i': true, 'J': true, 'L': true,
'l': true, 'm': true, 'o': true, 'O': true, 'p': true, 'l': true, 'm': true, 'o': true, 'O': true, 'p': true,
'Q': true, 'R': true, 'S': true, 'w': true, 'W': true, 'Q': true, 'R': true, 'S': true, 'w': true, 'W': true,
@@ -32,18 +32,16 @@ func Parse(args []string) *ParsedArgs {
} }
if strings.HasPrefix(arg, "-") && len(arg) > 1 { if strings.HasPrefix(arg, "-") && len(arg) > 1 {
flag := arg[1] // OpenSSH accepts clusters such as -vp 2222 and -vladmin.
if flagsWithArg[flag] { for j := 1; j < len(arg); j++ {
if len(arg) > 2 { if flagsWithArg[arg[j]] {
// argument is attached, e.g. -p2222 if j == len(arg)-1 {
i++
} else {
// argument is the next element, e.g. -p 2222
i += 2
}
} else {
i++ i++
} }
break
}
}
i++
continue continue
} }
@@ -86,18 +84,43 @@ func ReplaceHost(args []string, destIdx int, newHost string) []string {
return result return result
} }
// HasUserFlag reports whether a user was specified via -l in args. // HasPortFlag reports whether a port was explicitly supplied.
// Used to avoid overriding an explicit -l with the configured default user. func HasPortFlag(args []string) bool { return hasOption(args, 'p', "Port") }
func HasUserFlag(args []string) bool {
for i, a := range args { // HasUserFlag reports whether a login user was explicitly supplied.
if a == "-l" && i+1 < len(args) { func HasUserFlag(args []string) bool { return hasOption(args, 'l', "User") }
func hasOption(args []string, wanted byte, keyword string) bool {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
break
}
for j := 1; j < len(arg); j++ {
flag := arg[j]
if !flagsWithArg[flag] {
continue
}
value := arg[j+1:]
if value == "" {
i++
if i >= len(args) {
return false
}
value = args[i]
}
if flag == wanted {
return true return true
} }
// handle attached form: -lroot if flag == 'o' {
if len(a) > 2 && a[0] == '-' && a[1] == 'l' { fields := strings.FieldsFunc(value, func(r rune) bool { return r == '=' || r == ' ' || r == '\t' })
if len(fields) >= 2 && strings.EqualFold(fields[0], keyword) {
return true return true
} }
} }
break
}
}
return false return false
} }
+46
View File
@@ -0,0 +1,46 @@
package ssh
import "testing"
func TestParseOptionClusters(t *testing.T) {
for _, args := range [][]string{
{"-vp", "2222", "server"},
{"-vp2222", "server"},
{"-vAl", "admin", "server"},
{"-vladmin", "server"},
{"-B", "eth0", "server"},
{"-P", "production", "server"},
} {
t.Run(args[0], func(t *testing.T) {
assertParsed(t, Parse(args), "server", "", len(args)-1)
})
}
}
func TestExplicitSSHOptions(t *testing.T) {
for _, tc := range []struct {
name string
args []string
user, port bool
}{
{"cluster port separated", []string{"-vp", "2222", "server"}, false, true},
{"cluster user attached", []string{"-vladmin", "server"}, true, false},
{"o separated", []string{"-o", "User=admin", "-o", "Port=2222", "server"}, true, true},
{"o attached and case insensitive", []string{"-ouSeR=admin", "-oport=2222", "server"}, true, true},
{"o whitespace", []string{"-o", " User = admin", "-o", "Port\t2222", "server"}, true, true},
{"cluster o", []string{"-voUser=admin", "server"}, true, false},
{"option value looks like flag", []string{"-i", "-lroot", "-F", "-p2222", "server"}, false, false},
{"remote flags", []string{"server", "-p2222", "-o", "User=admin"}, false, false},
{"after double dash", []string{"--", "-lroot", "-p2222"}, false, false},
{"missing value", []string{"-vp"}, false, false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := HasUserFlag(tc.args); got != tc.user {
t.Errorf("HasUserFlag(%v) = %v, want %v", tc.args, got, tc.user)
}
if got := HasPortFlag(tc.args); got != tc.port {
t.Errorf("HasPortFlag(%v) = %v, want %v", tc.args, got, tc.port)
}
})
}
}
+38
View File
@@ -144,6 +144,37 @@ func TestHasUserFlag_LFlagAtEnd(t *testing.T) {
} }
} }
func TestHasPortFlag_FlagSeparated(t *testing.T) {
if !HasPortFlag([]string{"-p", "2222", "host"}) {
t.Error("should detect -p <port>")
}
}
func TestHasPortFlag_FlagAttached(t *testing.T) {
if !HasPortFlag([]string{"-p2222", "host"}) {
t.Error("should detect -p<port> (attached form)")
}
}
func TestHasPortFlag_NotPresent(t *testing.T) {
if HasPortFlag([]string{"-l", "admin", "host"}) {
t.Error("should not detect port flag when absent")
}
}
func TestHasPortFlag_EmptyArgs(t *testing.T) {
if HasPortFlag([]string{}) {
t.Error("empty args should return false")
}
}
func TestHasPortFlag_PAtEnd(t *testing.T) {
// -p at the very end with no value — should return false
if HasPortFlag([]string{"-p"}) {
t.Error("-p with no value should return false")
}
}
func assertParsed(t *testing.T, got *ParsedArgs, host, user string, destIdx int) { func assertParsed(t *testing.T, got *ParsedArgs, host, user string, destIdx int) {
t.Helper() t.Helper()
if got == nil { if got == nil {
@@ -159,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")
}
}
+32
View File
@@ -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)
}
+442 -34
View File
@@ -4,40 +4,58 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"strconv"
"strings" "strings"
"time" "time"
"github.com/charmbracelet/bubbles/list" "charm.land/bubbles/v2/list"
"github.com/charmbracelet/bubbles/textinput" "charm.land/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea" "charm.land/bubbles/v2/textinput"
"github.com/charmbracelet/lipgloss" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"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"
) )
// 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
Port string // empty = use default port
} }
// --- 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
} }
// --- list item --- // --- list item ---
type hostItem struct { type hostItem struct {
name string name string
id int
ip string ip string
kind string kind string
tags []string
} }
func (h hostItem) Title() string { return h.name } func (h hostItem) Title() string { return h.name }
@@ -60,93 +78,337 @@ func (d compactDelegate) Render(w io.Writer, m list.Model, index int, item list.
if index == m.Index() { if index == m.Index() {
line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " ")) line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " "))
} }
fmt.Fprintln(w, line) fmt.Fprint(w, line)
}
// --- filter ---
type filterOpts struct {
tags []string
kind string
}
func parseFilter(s string) filterOpts {
var f filterOpts
for _, part := range strings.Fields(s) {
if after, ok := strings.CutPrefix(part, "tag:"); ok {
f.tags = append(f.tags, after)
} else if after, ok := strings.CutPrefix(part, "kind:"); ok {
f.kind = after
}
}
return f
}
func applyFilter(entries []netbox.HostEntry, f filterOpts) []netbox.HostEntry {
if len(f.tags) == 0 && f.kind == "" {
return entries
}
out := make([]netbox.HostEntry, 0, len(entries))
for _, e := range entries {
if f.kind != "" && e.Kind != f.kind {
continue
}
if len(f.tags) > 0 {
tagSet := make(map[string]bool, len(e.Tags))
for _, t := range e.Tags {
tagSet[strings.ToLower(t)] = true
}
allMatch := true
for _, want := range f.tags {
if !tagSet[strings.ToLower(want)] {
allMatch = false
break
}
}
if !allMatch {
continue
}
}
out = append(out, e)
}
return out
} }
// --- Model --- // --- Model ---
type Model struct { type Model struct {
noteErr error
editHost *hostItem
noteInput textarea.Model
noteService *notes.Service
note notes.Note
noteMode, noteRemote, noteBusy bool
noteLoaded bool
defaultPort int
input textinput.Model input textinput.Model
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
editInput textinput.Model // user@host:port inline editor
list list.Model list list.Model
client *netbox.Client client *netbox.Client
cache *cache.Cache cache *cache.Cache
defaultUser string
lastSent string // last query sent to NetBox (or served from cache) lastSent string // last query sent to NetBox (or served from cache)
lastResults []netbox.HostEntry // raw results before filter applied
seq int // sequence number to discard stale results seq int // sequence number to discard stale results
loading bool loading bool
err error err error
selected *SelectedHost selected *SelectedHost
width int width int
height 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) *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()
fi := textinput.New()
fi.Placeholder = "tag:prod kind:vm"
fi.Prompt = "Filter: "
ei := textinput.New()
ei.Prompt = ""
l := list.New(nil, compactDelegate{}, 0, 0) l := list.New(nil, compactDelegate{}, 0, 0)
l.SetShowHelp(false) l.SetShowHelp(false)
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,
editInput: ei,
list: l, list: l,
client: client, client: client,
cache: c, cache: c,
defaultUser: defaultUser,
recentMode: true,
} }
} }
func (m *Model) Init() tea.Cmd { func (m *Model) Init() tea.Cmd {
return textinput.Blink return tea.Batch(textinput.Blink, m.loadRecent())
}
// loadRecent returns a Cmd that immediately resolves to the recently-used host list.
func (m *Model) loadRecent() tea.Cmd {
return func() tea.Msg {
if m.cache == nil {
return searchResultMsg{query: "", entries: nil, recent: true}
}
recent := m.cache.RecentlyUsed(10)
entries := make([]netbox.HostEntry, len(recent))
for i, e := range recent {
entries[i] = netbox.HostEntry{Name: e.Name, PrimaryIP4: e.IP, Kind: e.Kind, Tags: e.Tags}
}
return searchResultMsg{query: "", entries: entries, recent: true}
}
} }
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
defer m.resizeList()
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
m.height = msg.Height m.height = msg.Height
m.list.SetSize(msg.Width, msg.Height-4) m.input.SetWidth(max(1, msg.Width-lipgloss.Width(m.input.Prompt)))
m.filterInput.SetWidth(max(1, msg.Width-lipgloss.Width(m.filterInput.Prompt)))
m.editInput.SetWidth(max(1, msg.Width-lipgloss.Width("Connect as: ")))
m.noteInput.SetWidth(max(10, msg.Width-4))
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyPressMsg:
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 ---
if m.editMode {
switch msg.String() {
case "esc":
m.editMode = false
m.editInput.Blur()
m.input.Focus()
return m, nil
case "enter":
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)
}
return m, tea.Quit
}
var cmd tea.Cmd
m.editInput, cmd = m.editInput.Update(msg)
return m, cmd
}
// --- Filter focused ---
if m.filterOpen {
switch msg.String() {
case "ctrl+f", "esc":
m.filterOpen = false
m.filterInput.Blur()
m.filterInput.SetValue("")
m.input.Focus()
m.updateListItems(m.lastResults)
return m, nil
case "enter":
m.filterOpen = false
m.filterInput.Blur()
m.input.Focus()
m.updateListItems(m.lastResults)
return m, nil
}
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
m.updateListItems(m.lastResults)
return m, cmd
}
// --- Normal mode ---
switch msg.String() { switch msg.String() {
case "ctrl+c", "esc": case "ctrl+c", "esc":
return m, tea.Quit return m, tea.Quit
case "ctrl+f":
m.filterOpen = true
m.input.Blur()
m.filterInput.Focus()
return m, nil
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 "ctrl+e":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.openEdit(item)
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":
// Copy the top result into the search field.
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
} }
case debounceMsg: case debounceMsg:
// Only query if the input has changed since the last request.
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.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.query != m.lastSent { if msg.recent {
if m.input.Value() != "" || m.seq > 0 {
return m, nil
}
m.recentMode = true
m.lastResults = msg.entries
m.updateListItems(msg.entries)
return m, nil
}
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
@@ -154,25 +416,43 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = msg.err m.err = msg.err
return m, nil return m, nil
} }
items := make([]list.Item, len(msg.entries)) m.lastResults = msg.entries
for i, e := range msg.entries { m.updateListItems(msg.entries)
ip := e.PrimaryIP4
if ip == "" {
ip = e.PrimaryIP6
}
items[i] = hostItem{name: e.Name, ip: ip, kind: e.Kind}
}
m.list.SetItems(items)
m.err = nil m.err = nil
return m, nil return m, nil
} }
// Forward to text input and restart the debounce timer. if m.noteMode {
if m.noteBusy {
return m, nil
}
var cmd tea.Cmd
m.noteInput, cmd = m.noteInput.Update(msg)
return m, cmd
}
// Paste and cursor events are separate from key presses in Bubble Tea v2.
// Route them to the focused editor as well.
if m.editMode {
var cmd tea.Cmd
m.editInput, cmd = m.editInput.Update(msg)
return m, cmd
}
if m.filterOpen {
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
m.updateListItems(m.lastResults)
return m, cmd
}
// 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)
if m.input.Value() != oldQuery {
m.seq++
cmds = append(cmds, m.startDebounce()) 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)
@@ -181,21 +461,61 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
func (m *Model) View() string { func (m *Model) View() tea.View {
view := tea.NewView(m.viewContent())
view.AltScreen = true
return view
}
func (m *Model) viewContent() 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")
sb.WriteString(title + "\n\n") sb.WriteString(title + "\n\n")
sb.WriteString(m.input.View() + "\n") sb.WriteString(m.input.View() + "\n")
if m.filterOpen {
sb.WriteString(m.filterInput.View() + "\n")
}
if m.loading { if m.loading {
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n") sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n")
} else if m.err != nil { } else if m.err != nil {
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(" error: "+m.err.Error()) + "\n") sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(" error: "+m.err.Error()) + "\n")
} else { } else {
if m.recentMode && m.input.Value() == "" {
if len(m.list.Items()) > 0 {
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n")
}
}
sb.WriteString(m.list.View()) sb.WriteString(m.list.View())
} }
if m.editMode {
sb.WriteString("\n")
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Connect as: "))
sb.WriteString(m.editInput.View() + "\n")
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
} else {
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")
}
return sb.String() return sb.String()
} }
@@ -205,33 +525,121 @@ 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}
}) })
} }
func (m *Model) doSearch(query string, seq int) tea.Cmd { func (m *Model) doSearch(query string, seq int) tea.Cmd {
opts := m.netboxSearchOpts()
return func() tea.Msg { return func() tea.Msg {
// Return cache hits immediately without a network round-trip. // Return cache hits immediately without a network round-trip.
if m.cache != nil { if m.cache != nil {
if cached := m.cache.Search(query); len(cached) > 0 { if cached := m.cache.Search(query); len(cached) > 0 {
entries := make([]netbox.HostEntry, len(cached)) entries := make([]netbox.HostEntry, len(cached))
for i, c := range cached { for i, c := range cached {
entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind} 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) 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}
} }
} }
// netboxSearchOpts derives SearchOptions from the current filter input.
func (m *Model) netboxSearchOpts() netbox.SearchOptions {
f := parseFilter(m.filterInput.Value())
var tag string
if len(f.tags) > 0 {
tag = f.tags[0]
}
return netbox.SearchOptions{Tag: tag, Kind: f.kind}
}
// updateListItems applies the active filter and sets the list items.
func (m *Model) updateListItems(entries []netbox.HostEntry) {
f := parseFilter(m.filterInput.Value())
filtered := applyFilter(entries, f)
items := make([]list.Item, len(filtered))
for i, e := range filtered {
ip := e.PrimaryIP4
if ip == "" {
ip = e.PrimaryIP6
}
items[i] = hostItem{id: e.ID, name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags}
}
m.list.SetItems(items)
m.resizeList()
}
// resizeList keeps the footer visible when the recent heading or editors change.
func (m *Model) resizeList() {
if m.width <= 0 || m.height <= 0 {
return
}
extraRows := 5 // title, blank line, search, footer separator, footer
if m.recentMode && m.input.Value() == "" && len(m.list.Items()) > 0 {
extraRows++
}
if m.filterOpen {
extraRows++
}
if m.editMode {
extraRows++
}
height := max(1, m.height-extraRows)
if m.list.Width() != m.width || m.list.Height() != height {
m.list.SetSize(m.width, height)
}
}
// openEdit switches to edit mode for the given list item.
func (m *Model) openEdit(item hostItem) {
m.editMode = true
m.editHost = &item
m.input.Blur()
user := m.defaultUser
prefix := ""
if user != "" {
prefix = user + "@"
}
m.editInput.SetValue(fmt.Sprintf("%s%s:%d", prefix, item.name, m.defaultPort))
m.editInput.CursorEnd()
m.editInput.Focus()
}
// buildSelected parses the editInput to extract user/port overrides.
func (m *Model) buildSelected(item hostItem) *SelectedHost {
s := strings.TrimSpace(m.editInput.Value())
sel := &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
// Extract user: everything before @
if idx := strings.Index(s, "@"); idx != -1 {
sel.User = strings.TrimSpace(s[:idx])
s = s[idx+1:]
}
// Extract port: after the last colon, if it's a number
if idx := strings.LastIndex(s, ":"); idx != -1 {
portStr := strings.TrimSpace(s[idx+1:])
if port, err := strconv.Atoi(portStr); err == nil && port > 0 && port <= 65535 {
sel.Port = portStr
}
}
return sel
}
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }
+72
View File
@@ -0,0 +1,72 @@
package tui
import (
"testing"
tea "charm.land/bubbletea/v2"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
)
func TestTypingEDoesNotOpenConnectionEditor(t *testing.T) {
m := New(nil, nil, "")
m.updateListItems([]netbox.HostEntry{{Name: "server"}})
m.Update(tea.KeyPressMsg{Code: 'e', Text: "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(&notes.Service{Dir: t.TempDir()})
m.noteMode = true
_, cmd := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl})
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.KeyPressMsg{Code: tea.KeyEnter})
if m.selected == nil || m.selected.Name != "first" {
t.Fatalf("edited host changed: %+v", m.selected)
}
}
+123
View File
@@ -0,0 +1,123 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
func TestV2PasteFollowsFocusedEditor(t *testing.T) {
t.Run("search", func(t *testing.T) {
m := New(nil, nil, "")
_, cmd := m.Update(tea.PasteMsg{Content: "web-ä"})
if m.input.Value() != "web-ä" || m.seq != 1 || cmd == nil {
t.Fatalf("paste did not start search: %q, seq %d", m.input.Value(), m.seq)
}
})
t.Run("connection editor", func(t *testing.T) {
m := New(nil, nil, "")
m.openEdit(hostItem{name: "web", ip: "10.0.0.1"})
m.editInput.SetValue("")
m.Update(tea.PasteMsg{Content: "ops@web:2222"})
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if m.selected == nil || m.selected.User != "ops" || m.selected.Port != "2222" {
t.Fatalf("paste lost in editor: %+v", m.selected)
}
if m.input.Value() != "" {
t.Fatal("editor paste changed search")
}
})
t.Run("filter", func(t *testing.T) {
m := New(nil, nil, "")
m.lastResults = []netbox.HostEntry{{Name: "vm", Kind: "vm"}, {Name: "device", Kind: "device"}}
m.updateListItems(m.lastResults)
m.Update(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
m.Update(tea.PasteMsg{Content: "kind:vm"})
if m.filterInput.Value() != "kind:vm" || len(m.list.Items()) != 1 {
t.Fatalf("paste did not filter: %q, %d results", m.filterInput.Value(), len(m.list.Items()))
}
if m.input.Value() != "" {
t.Fatal("filter paste changed search")
}
})
t.Run("notes", func(t *testing.T) {
m := New(nil, nil, "")
m.noteMode = true
m.input.Blur()
m.noteInput.Focus()
m.Update(tea.PasteMsg{Content: "First line\nSecond line"})
if m.noteInput.Value() != "First line\nSecond line" {
t.Fatalf("multiline paste lost: %q", m.noteInput.Value())
}
m.noteBusy = true
m.Update(tea.PasteMsg{Content: "must not change busy note"})
if m.noteInput.Value() != "First line\nSecond line" {
t.Fatal("paste edited note while loading/saving")
}
})
}
func TestV2ViewKeepsAlternateScreenInEachMode(t *testing.T) {
for _, mode := range []string{"search", "edit", "notes"} {
t.Run(mode, func(t *testing.T) {
m := New(nil, nil, "")
m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
if mode == "edit" {
m.openEdit(hostItem{name: "web"})
}
if mode == "notes" {
m.noteMode = true
}
view := m.View()
if !view.AltScreen || strings.TrimSpace(view.Content) == "" {
t.Fatalf("invalid %s view: %+v", mode, view)
}
})
}
}
func TestV2KeyReleaseDoesNotTypeTwice(t *testing.T) {
m := New(nil, nil, "")
m.Update(tea.KeyPressMsg{Code: 'e', Text: "e"})
m.Update(tea.KeyReleaseMsg{Code: 'e', Text: "e"})
if m.input.Value() != "e" {
t.Fatalf("key release changed input: %q", m.input.Value())
}
m.Update(tea.KeyPressMsg{Code: ' ', Text: " "})
if m.input.Value() != "e " {
t.Fatalf("space not inserted: %q", m.input.Value())
}
}
func TestV2ResizeShowsFullSearchPromptAndFooter(t *testing.T) {
m := New(nil, nil, "")
m.Update(tea.WindowSizeMsg{Width: 100, Height: 24})
var entries []netbox.HostEntry
for range 30 {
entries = append(entries, netbox.HostEntry{Name: "web", Kind: "device"})
}
m.updateListItems(entries)
view := m.View()
if m.input.Width() != 98 || m.filterInput.Width() != 92 || m.editInput.Width() != 88 {
t.Fatalf("editors were not sized for terminal: search=%d filter=%d edit=%d", m.input.Width(), m.filterInput.Width(), m.editInput.Width())
}
if !strings.Contains(view.Content, "earch hostname") || !strings.Contains(view.Content, "ctrl+c quit") {
t.Fatalf("search prompt or footer missing: %s", view.Content)
}
assertFits := func() {
t.Helper()
if rows := strings.Count(m.View().Content, "\n"); rows > 24 {
t.Fatalf("view overflows terminal: %d rows", rows)
}
}
assertFits()
m.lastResults = entries
m.Update(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
assertFits()
m.Update(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl})
assertFits()
}
+57
View File
@@ -0,0 +1,57 @@
package netssh_test
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestReleaseNotesUseReleaseAssetAndVersionRange(t *testing.T) {
script, err := filepath.Abs("scripts/release-notes.sh")
if err != nil {
t.Fatal(err)
}
repo := t.TempDir()
git := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repo
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Release Test", "GIT_AUTHOR_EMAIL=release@example.com", "GIT_COMMITTER_NAME=Release Test", "GIT_COMMITTER_EMAIL=release@example.com", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
git("init")
git("-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "Initial version")
git("tag", "v0.0.1")
render := func(tag string) string {
t.Helper()
cmd := exec.Command("bash", script, tag, "https://git.example.com/team/netssh/")
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("render: %v\n%s", err, out)
}
return string(out)
}
initial := render("v0.0.1")
if !strings.Contains(initial, "Initial release:") || !strings.Contains(initial, "Initial version") {
t.Fatalf("first release: %s", initial)
}
git("-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", `Add installer and "quoted" release notes`)
git("tag", "v0.0.2")
git("-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "Unreleased changes")
notes := render("v0.0.2")
for _, want := range []string{"https://git.example.com/team/netssh/releases/download/v0.0.2/install.sh", "Changes since `v0.0.1`:", `Add installer and "quoted" release notes`, "checksums.txt"} {
if !strings.Contains(notes, want) {
t.Errorf("missing %q:\n%s", want, notes)
}
}
for _, excluded := range []string{"Initial version", "Unreleased changes", "raw/branch/main"} {
if strings.Contains(notes, excluded) {
t.Errorf("unexpected %q:\n%s", excluded, notes)
}
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"dependencyDashboard": true,
"postUpdateOptions": [
"gomodTidy"
],
"packageRules": [
{
"description": "Keep GitHub Actions in the Gitea release workflow up to date",
"matchManagers": [
"github-actions"
],
"enabled": true
},
{
"description": "Include indirect Go modules in dependency updates",
"matchManagers": [
"gomod"
],
"matchDepTypes": [
"indirect"
],
"enabled": true
},
{
"description": "Update the Go version declared in go.mod",
"matchManagers": [
"gomod"
],
"matchDepNames": [
"go"
],
"matchDepTypes": [
"golang"
],
"rangeStrategy": "bump"
}
]
}
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 2 ]]; then
printf 'Usage: %s TAG REPOSITORY_URL\n' "$0" >&2
exit 1
fi
TAG=$1
REPOSITORY_URL=${2%/}
git rev-parse --verify "$TAG^{commit}" >/dev/null
# Start at the parent so the release being built cannot match itself.
PREVIOUS_TAG=$(git describe --tags --abbrev=0 --match 'v[0-9]*' "$TAG^" 2>/dev/null || true)
printf '## Installation / Update\n\n'
printf '```sh\ncurl -fsSL %s/releases/download/%s/install.sh | bash\n```\n\n' "$REPOSITORY_URL" "$TAG"
printf 'The installer is attached to this release. It checks dependencies and verifies binary SHA-256 checksums before installation. Python is not required.\n\n'
printf 'Download `checksums.txt` to verify the attached binaries and `install.sh`.\n\n'
printf '## Changes\n\n'
if [[ -n "$PREVIOUS_TAG" ]]; then
printf 'Changes since `%s`:\n\n' "$PREVIOUS_TAG"
git log --no-merges --format='- %s (%h)' "$PREVIOUS_TAG..$TAG"
else
printf 'Initial release:\n\n'
git log --no-merges --format='- %s (%h)' "$TAG"
fi