Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92b1c40cb8 | ||
|
|
34503de68e | ||
|
|
14d519d010 | ||
|
|
fa4509038b | ||
|
|
66a49786c9 | ||
|
|
4b28d441b0 |
No files matched your search
@@ -8,9 +8,14 @@ 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@v7
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/setup-go@v7
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -46,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
|
||||||
@@ -68,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
|
||||||
@@ -109,6 +144,39 @@ shortcuts:
|
|||||||
|
|
||||||
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
||||||
|
|
||||||
|
### Short command alias
|
||||||
|
|
||||||
|
In `netssh configure`, set **Command alias** to `j` to use `j` anywhere you would
|
||||||
|
use `netssh`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
j # interactive server search
|
||||||
|
j server01 # connect to a server
|
||||||
|
j notes show server01
|
||||||
|
```
|
||||||
|
|
||||||
|
The setting is saved as:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
shell:
|
||||||
|
alias: j
|
||||||
|
```
|
||||||
|
|
||||||
|
The wizard regenerates shell aliases and installs the current shell's hook when
|
||||||
|
an alias is set. Open a new terminal (or follow the displayed reload command).
|
||||||
|
Changing the setting replaces the old generated alias; leaving it empty disables
|
||||||
|
it for new shells. The command alias takes priority over a host alias with the
|
||||||
|
same name and works even when no servers are cached. Environment override:
|
||||||
|
`NETSSH_SHELL_ALIAS=j`.
|
||||||
|
|
||||||
|
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:
|
||||||
@@ -456,8 +524,12 @@ Both are non-blocking — the refresh runs as a background process and never del
|
|||||||
|
|
||||||
## Development
|
## 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
|
```sh
|
||||||
go test ./... # run all tests
|
go test ./... # includes offline installer/update tests
|
||||||
go build ./... # build all packages
|
go build ./... # build all packages
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+16
-25
@@ -366,8 +366,7 @@ func completionInstallCmd(root *cobra.Command) *cobra.Command {
|
|||||||
}
|
}
|
||||||
note = "Make sure ~/.zfunc is in your fpath:\n fpath=(~/.zfunc $fpath)\n autoload -Uz compinit && compinit"
|
note = "Make sure ~/.zfunc is in your fpath:\n fpath=(~/.zfunc $fpath)\n autoload -Uz compinit && compinit"
|
||||||
case "fish":
|
case "fish":
|
||||||
configDir, _ := os.UserConfigDir()
|
dir = filepath.Join(hook.FishConfigDir(), "completions")
|
||||||
dir = filepath.Join(configDir, "fish", "completions")
|
|
||||||
file = filepath.Join(dir, "netssh.fish")
|
file = filepath.Join(dir, "netssh.fish")
|
||||||
gen = func() ([]byte, error) {
|
gen = func() ([]byte, error) {
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
@@ -389,7 +388,17 @@ func completionInstallCmd(root *cobra.Command) *cobra.Command {
|
|||||||
if err := os.WriteFile(file, script, 0o644); err != nil {
|
if err := os.WriteFile(file, script, 0o644); err != nil {
|
||||||
return fmt.Errorf("writing %s: %w", file, err)
|
return fmt.Errorf("writing %s: %w", file, err)
|
||||||
}
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := hook.SyncCommandCompletion(cfg.Shell.Alias); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
fmt.Printf("Completion installed → %s\n%s\n", file, note)
|
fmt.Printf("Completion installed → %s\n%s\n", file, note)
|
||||||
|
if cfg.Shell.Alias != "" {
|
||||||
|
fmt.Printf("Completion also enabled for alias %q.\n", cfg.Shell.Alias)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -570,23 +579,7 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
|
|
||||||
// buildAliasEntries computes the sorted, deduplicated alias list from the cache.
|
// buildAliasEntries computes the sorted, deduplicated alias list from the cache.
|
||||||
func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry {
|
func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry {
|
||||||
all := c.All()
|
return hook.BuildAliasEntries(c.All(), cfg)
|
||||||
sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name })
|
|
||||||
|
|
||||||
seen := make(map[string]string)
|
|
||||||
var out []hook.AliasEntry
|
|
||||||
for _, e := range all {
|
|
||||||
name := shortcuts.AliasName(e.Name, cfg.Shortcuts)
|
|
||||||
if name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, dup := seen[name]; dup {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[name] = e.Name
|
|
||||||
out = append(out, hook.AliasEntry{Name: name, Host: e.Name})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func aliasCmd() *cobra.Command {
|
func aliasCmd() *cobra.Command {
|
||||||
@@ -627,12 +620,12 @@ Or use in a script:
|
|||||||
case "fish":
|
case "fish":
|
||||||
fmt.Printf("# netssh aliases (%d hosts) — source with: netssh alias --shell fish | source\n", len(entries))
|
fmt.Printf("# netssh aliases (%d hosts) — source with: netssh alias --shell fish | source\n", len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
fmt.Printf("alias %s 'netssh %s'\n", e.Name, e.Host)
|
fmt.Println(e.Definition("fish"))
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(entries))
|
fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
fmt.Printf("alias %s='netssh %s'\n", e.Name, e.Host)
|
fmt.Println(e.Definition("bash"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -651,7 +644,7 @@ func shellInitCmd() *cobra.Command {
|
|||||||
Hidden: true,
|
Hidden: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil || cfg.NetBox.URL == "" {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||||
@@ -659,9 +652,7 @@ func shellInitCmd() *cobra.Command {
|
|||||||
|
|
||||||
// Regenerate alias files from the current cache every shell start
|
// Regenerate alias files from the current cache every shell start
|
||||||
// so aliases are always up-to-date even before a refresh completes.
|
// so aliases are always up-to-date even before a refresh completes.
|
||||||
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
|
_ = hook.WriteAliasFiles(buildAliasEntries(c, *cfg))
|
||||||
_ = hook.WriteAliasFiles(entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
maybeBackgroundRefresh(cfg, c)
|
maybeBackgroundRefresh(cfg, c)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -38,3 +40,37 @@ func TestLocalNotesCLI(t *testing.T) {
|
|||||||
t.Fatalf("clear: %q, %v", out, err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,48 +2,55 @@ module git.zb-server.de/Sebi/ssh-netbox-wrapper
|
|||||||
|
|
||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/huh v1.0.0
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
|
github.com/spf13/viper v1.21.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/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/aymanbagabas/go-udiff v0.4.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/x/ansi v0.11.9 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
|
||||||
github.com/charmbracelet/huh v1.0.0 // indirect
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // 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/exp/golden v0.1.0 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.1.0 // 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/xpty v0.1.4 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // 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-isatty v0.0.24 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.31 // 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/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/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.4.3 // 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/sys v0.49.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.43.0 // indirect
|
||||||
)
|
)
|
||||||
@@ -1,52 +1,74 @@
|
|||||||
|
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-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||||
|
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/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
||||||
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY=
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.1.0 h1:qgUxGaHX4I8Fg077vD+54+qFmDWrgO19lTCQLblHWko=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.1.0/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
|
||||||
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/xpty v0.1.4 h1:4jaW7u+8AHQMxesiVc+zUMsspu7GyDwtJO+gy/tFtW4=
|
||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/charmbracelet/x/xpty v0.1.4/go.mod h1:7t8P7BpPiolHJ1pLzz7/4ujDbD+sUxI9yA3CBOLOIcU=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
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/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||||
|
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
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 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
@@ -55,17 +77,17 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
|||||||
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/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.4.3/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 +99,18 @@ 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=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
|
||||||
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=
|
||||||
+214
-68
@@ -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
|
||||||
|
[[ "$INSTALL_DIR" == /* ]] || INSTALL_DIR="$PWD/$INSTALL_DIR"
|
||||||
|
# Check sudo only when the destination needs elevated permissions, also before
|
||||||
|
# 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
|
case "$(uname -s)" in
|
||||||
Linux) echo linux ;;
|
Linux) PLATFORM_OS=linux ;;
|
||||||
Darwin) echo darwin ;;
|
Darwin) PLATFORM_OS=darwin ;;
|
||||||
*) die "unsupported OS: $(uname -s)" ;;
|
*) die "unsupported OS: $(uname -s)" ;;
|
||||||
esac
|
esac
|
||||||
}
|
|
||||||
|
|
||||||
detect_arch() {
|
|
||||||
case "$(uname -m)" in
|
case "$(uname -m)" in
|
||||||
x86_64|amd64) echo amd64 ;;
|
x86_64|amd64) PLATFORM_ARCH=amd64 ;;
|
||||||
aarch64|arm64) echo arm64 ;;
|
aarch64|arm64) PLATFORM_ARCH=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" \
|
||||||
info "verifying checksum..."
|
|| die "could not fetch checksums; installation aborted"
|
||||||
curl -sf -o "$TMP/checksums.txt" "$CHECKSUM_URL" \
|
EXPECTED=$(awk -v asset="$ASSET" '$2 == asset || $2 == "*" asset {print tolower($1)}' "$TMP/checksums.txt")
|
||||||
|| { info "warning: could not fetch checksums, skipping verification"; }
|
[[ "$EXPECTED" =~ ^[a-f0-9]{64}$ ]] || die "missing, duplicate, or invalid checksum; installation aborted"
|
||||||
|
ACTUAL=$("${SHA256[@]}" "$TMP/$ASSET")
|
||||||
if [[ -f "$TMP/checksums.txt" ]]; then
|
ACTUAL=${ACTUAL%% *}
|
||||||
# checksums.txt was built with `sha256sum` in the dist/ dir, so entries look
|
[[ "$ACTUAL" == "$EXPECTED" ]] || die "checksum mismatch; installation aborted"
|
||||||
# like "abc123 netssh_linux_amd64". We need to cd into the temp dir first.
|
|
||||||
(cd "$TMP" && grep "$ASSET" checksums.txt | sha256sum --check --status) \
|
|
||||||
|| die "checksum mismatch — aborting installation"
|
|
||||||
info "checksum OK"
|
info "checksum OK"
|
||||||
fi
|
|
||||||
|
|
||||||
chmod +x "$TMP/$ASSET"
|
# Only standalone commands and our managed blocks are removed. Unrelated content
|
||||||
|
# and commands which merely mention netssh are retained.
|
||||||
|
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
|
||||||
|
|
||||||
# Install — try without sudo first, fall back if needed.
|
# POSIX-style single quoting also works for these paths in fish, except embedded
|
||||||
if [[ -w "$INSTALL_DIR" ]]; then
|
# quotes, which fish escapes with a backslash instead of concatenated quotes.
|
||||||
mv "$TMP/$ASSET" "$INSTALL_DIR/$BINARY"
|
quote_sh() { printf "'%s'" "${1//\'/\'\\\'\'}"; }
|
||||||
|
quote_fish() {
|
||||||
|
local value=${1//\\/\\\\}
|
||||||
|
value=${value//\'/\\\'}
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
PROFILE_PATHS=()
|
||||||
|
PROFILE_TARGETS=()
|
||||||
|
PROFILE_DATA=()
|
||||||
|
SHELLS=()
|
||||||
|
CURRENT_SHELL=${SHELL:-}
|
||||||
|
CURRENT_SHELL=${CURRENT_SHELL##*/}
|
||||||
|
CONFIG_DIR=${XDG_CONFIG_HOME:-$HOME/.config}
|
||||||
|
if [[ "$PLATFORM_OS" == darwin ]]; then
|
||||||
|
CACHE_DIR=${XDG_CACHE_HOME:-$HOME/Library/Caches}
|
||||||
else
|
else
|
||||||
info "sudo required to write to $INSTALL_DIR"
|
CACHE_DIR=${XDG_CACHE_HOME:-$HOME/.cache}
|
||||||
sudo mv "$TMP/$ASSET" "$INSTALL_DIR/$BINARY"
|
|
||||||
fi
|
fi
|
||||||
|
ZSH_DIR=${ZDOTDIR:-$HOME}
|
||||||
|
plan_profile() {
|
||||||
|
local path=$1 kind=$2 target data previous count
|
||||||
|
if [[ ! -e "$path" && ! -L "$path" ]] && { [[ -z "$kind" ]] || [[ "$kind" != "$CURRENT_SHELL" ]]; }; then return; fi
|
||||||
|
target=$(resolve_profile "$path")
|
||||||
|
# A dotfile manager may point two profile names at the same file.
|
||||||
|
for previous in "${PROFILE_TARGETS[@]}"; do [[ "$previous" != "$target" ]] || return 0; done
|
||||||
|
data="$TMP/profile-${#PROFILE_PATHS[@]}"
|
||||||
|
if [[ -e "$target" ]]; then
|
||||||
|
awk -f "$TMP/clean-profile.awk" "$target" > "$data" \
|
||||||
|
|| die "unclosed or nested netssh block in $path; profiles were not changed"
|
||||||
|
else
|
||||||
|
: > "$data"
|
||||||
|
fi
|
||||||
|
if [[ -n "$kind" ]]; then
|
||||||
|
count=0
|
||||||
|
for previous in "${SHELLS[@]}"; do [[ "$previous" != "$kind" ]] || count=1; done
|
||||||
|
if [[ "$count" == 0 ]]; then SHELLS+=("$kind"); fi
|
||||||
|
{
|
||||||
|
printf '# >>> netssh >>>\n'
|
||||||
|
if [[ "$kind" == fish ]]; then
|
||||||
|
printf 'set -gx PATH %s $PATH\n' "$(quote_fish "$INSTALL_DIR")"
|
||||||
|
printf '%s shell-init\n' "$(quote_fish "$INSTALL_DIR/$BINARY")"
|
||||||
|
else
|
||||||
|
printf 'export PATH=%s:"$PATH"\n' "$(quote_sh "$INSTALL_DIR")"
|
||||||
|
printf '%s shell-init\n' "$(quote_sh "$INSTALL_DIR/$BINARY")"
|
||||||
|
printf '[ ! -f %s ] || . %s\n' "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")" "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")"
|
||||||
|
fi
|
||||||
|
printf '# <<< netssh <<<\n'
|
||||||
|
} >> "$data"
|
||||||
|
fi
|
||||||
|
PROFILE_PATHS+=("$path"); PROFILE_TARGETS+=("$target"); PROFILE_DATA+=("$data")
|
||||||
|
}
|
||||||
|
# Preflight every profile before installing the binary or modifying any profile.
|
||||||
|
plan_profile "$HOME/.bashrc" bash
|
||||||
|
plan_profile "$HOME/.bash_profile" ""
|
||||||
|
plan_profile "$HOME/.bash_login" ""
|
||||||
|
plan_profile "$HOME/.profile" ""
|
||||||
|
plan_profile "$ZSH_DIR/.zshrc" zsh
|
||||||
|
plan_profile "$ZSH_DIR/.zprofile" ""
|
||||||
|
plan_profile "$ZSH_DIR/.zshenv" ""
|
||||||
|
if [[ "$ZSH_DIR" != "$HOME" ]]; then
|
||||||
|
plan_profile "$HOME/.zshrc" ""
|
||||||
|
plan_profile "$HOME/.zprofile" ""
|
||||||
|
plan_profile "$HOME/.zshenv" ""
|
||||||
|
fi
|
||||||
|
plan_profile "$CONFIG_DIR/fish/config.fish" fish
|
||||||
|
|
||||||
echo
|
if [[ ${#PRIVILEGE[@]} -gt 0 ]]; then
|
||||||
green "✓ netssh $TAG installed to $INSTALL_DIR/$BINARY"
|
info "sudo required to write to $INSTALL_DIR"
|
||||||
info "run 'netssh --help' to get started"
|
fi
|
||||||
|
"${PRIVILEGE[@]}" mkdir -p "$INSTALL_DIR"
|
||||||
|
# Stage on the destination filesystem to replace a running binary atomically.
|
||||||
|
STAGED=$("${PRIVILEGE[@]}" mktemp "$INSTALL_DIR/.netssh.XXXXXX")
|
||||||
|
"${PRIVILEGE[@]}" install -m 755 "$TMP/$ASSET" "$STAGED"
|
||||||
|
"${PRIVILEGE[@]}" mv -f "$STAGED" "$INSTALL_DIR/$BINARY"
|
||||||
|
STAGED=""
|
||||||
|
|
||||||
|
# Regenerate aliases, including the configured command alias, as the calling user.
|
||||||
|
# An empty cache clears obsolete generated aliases. Never run the wizard here.
|
||||||
|
write_aliases() {
|
||||||
|
local kind=$1 path=$2 staged
|
||||||
|
"$INSTALL_DIR/$BINARY" alias --shell "$kind" > "$TMP/aliases-$kind" \
|
||||||
|
|| die "could not regenerate $kind aliases; profiles were not changed"
|
||||||
|
mkdir -p "$(dirname "$path")"
|
||||||
|
staged=$(mktemp "$(dirname "$path")/.netssh.XXXXXX")
|
||||||
|
cat "$TMP/aliases-$kind" > "$staged"
|
||||||
|
chmod 600 "$staged"
|
||||||
|
mv -f "$staged" "$path"
|
||||||
|
}
|
||||||
|
write_aliases bash "$CACHE_DIR/netssh/aliases.sh"
|
||||||
|
for kind in "${SHELLS[@]}"; do
|
||||||
|
if [[ "$kind" == fish ]]; then write_aliases fish "$CONFIG_DIR/fish/conf.d/netssh.fish"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
for ((index=0; index<${#PROFILE_PATHS[@]}; index++)); do
|
||||||
|
target=${PROFILE_TARGETS[$index]}
|
||||||
|
data=${PROFILE_DATA[$index]}
|
||||||
|
if [[ -e "$target" ]] && cmp -s "$target" "$data"; then continue; fi
|
||||||
|
mkdir -p "$(dirname "$target")"
|
||||||
|
if [[ -e "$target" ]]; then
|
||||||
|
backup=$(mktemp "$target.netssh-backup.XXXXXX")
|
||||||
|
cp -p "$target" "$backup"
|
||||||
|
info "backup : $backup"
|
||||||
|
fi
|
||||||
|
staged=$(mktemp "$(dirname "$target")/.netssh.XXXXXX")
|
||||||
|
if [[ -e "$target" ]]; then cp -p "$target" "$staged"; else chmod 644 "$staged"; fi
|
||||||
|
cat "$data" > "$staged"
|
||||||
|
mv -f "$staged" "$target"
|
||||||
|
info "updated : ${PROFILE_PATHS[$index]}"
|
||||||
|
done
|
||||||
|
for kind in "${SHELLS[@]}"; do
|
||||||
|
if ! "$INSTALL_DIR/$BINARY" completion install --shell "$kind"; then
|
||||||
|
info "warning: could not update $kind completion"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf '\nnetssh %s installed / updated at %s/%s\n' "$TAG" "$INSTALL_DIR" "$BINARY"
|
||||||
|
info "Open a new terminal to load the current hooks and aliases."
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
package netssh_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type installerFixture struct {
|
||||||
|
home, bin, assets string
|
||||||
|
env []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInstallerFixture(t *testing.T) installerFixture {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
f := installerFixture{home: filepath.Join(root, "home"), bin: filepath.Join(root, "tools"), assets: filepath.Join(root, "assets")}
|
||||||
|
for _, dir := range []string{f.home, f.bin, f.assets} {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeFixture(t, filepath.Join(f.bin, "curl"), `#!/bin/bash
|
||||||
|
set -eu
|
||||||
|
out=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in -o) out=$2; shift 2 ;; *) url=$1; shift ;; esac
|
||||||
|
done
|
||||||
|
case "$url" in
|
||||||
|
*/releases/latest) printf '{"tag_name": "v0.0.99"}\n'; exit ;;
|
||||||
|
*/checksums.txt) [[ "${CHECKSUM_MODE:-}" != missing ]] || exit 22; file="$FIXTURE_ASSETS/checksums.txt" ;;
|
||||||
|
*) file="$FIXTURE_ASSETS/netssh_linux_amd64" ;;
|
||||||
|
esac
|
||||||
|
if [[ -n "$out" ]]; then cp "$file" "$out"; else cat "$file"; fi
|
||||||
|
`, 0o755)
|
||||||
|
writeFixture(t, filepath.Join(f.bin, "uname"), "#!/bin/sh\ncase \"$1\" in -s) echo Linux;; -m) echo x86_64;; esac\n", 0o755)
|
||||||
|
// Any accidental runtime dependency on Python must fail the installation.
|
||||||
|
writeFixture(t, filepath.Join(f.bin, "python3"), "#!/bin/sh\nexit 99\n", 0o755)
|
||||||
|
binary := `#!/bin/bash
|
||||||
|
set -eu
|
||||||
|
case "$1" in
|
||||||
|
alias)
|
||||||
|
if [[ "$3" == fish ]]; then printf "alias j 'netssh'\n"; else printf "alias j='netssh'\n"; fi ;;
|
||||||
|
completion) exit 0 ;;
|
||||||
|
shell-init) exit 0 ;;
|
||||||
|
*) exit 1 ;;
|
||||||
|
esac
|
||||||
|
`
|
||||||
|
writeFixture(t, filepath.Join(f.assets, "netssh_linux_amd64"), binary, 0o755)
|
||||||
|
checksum := fmt.Sprintf("%x netssh_linux_amd64\n", sha256.Sum256([]byte(binary)))
|
||||||
|
writeFixture(t, filepath.Join(f.assets, "checksums.txt"), checksum, 0o644)
|
||||||
|
f.env = append(os.Environ(), "HOME="+f.home, "XDG_CONFIG_HOME="+filepath.Join(f.home, "config"), "XDG_CACHE_HOME="+filepath.Join(f.home, "cache"), "ZDOTDIR="+f.home, "SHELL=/bin/bash", "PATH="+f.bin+":"+os.Getenv("PATH"), "FIXTURE_ASSETS="+f.assets, "INSTALL_DIR="+filepath.Join(f.home, "local bin's"), "BASH_ENV=", "CHECKSUM_MODE=")
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFixture(t *testing.T, path, content string, mode os.FileMode) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f installerFixture) run(t *testing.T) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
// Piping stdin mirrors curl | bash, including EOF for any child commands.
|
||||||
|
script, err := os.ReadFile("install.sh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd := exec.Command("bash")
|
||||||
|
cmd.Env = f.env
|
||||||
|
cmd.Stdin = strings.NewReader(string(script))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerUpdatesAndConsolidatesProfilesWithoutPython(t *testing.T) {
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
original := "export KEEP=yes\n# netssh shell-init is documented here\necho 'netssh shell-init'\nnetssh shell-init\nnetssh shell-init # duplicate\nsource ~/.cache/netssh/aliases.sh 2>/dev/null\neval \"$(netssh alias)\"\nalias old='netssh old.example.com'\n"
|
||||||
|
target := filepath.Join(f.home, "dotfiles", "bashrc")
|
||||||
|
writeFixture(t, target, original, 0o600)
|
||||||
|
bashrc := filepath.Join(f.home, ".bashrc")
|
||||||
|
if err := os.Symlink(target, bashrc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFixture(t, filepath.Join(f.home, ".zshrc"), "export ZSH_KEEP=yes\nnetssh shell-init\n", 0o644)
|
||||||
|
writeFixture(t, filepath.Join(f.home, ".profile"), "export LOGIN_KEEP=yes\nnetssh shell-init\n", 0o644)
|
||||||
|
fish := filepath.Join(f.home, "config", "fish", "config.fish")
|
||||||
|
writeFixture(t, fish, "set KEEP yes\nnetssh alias --shell fish | source\n", 0o644)
|
||||||
|
installed := filepath.Join(f.home, "local bin's", "netssh")
|
||||||
|
writeFixture(t, installed, "old binary", 0o755)
|
||||||
|
if out, err := f.run(t); err != nil {
|
||||||
|
t.Fatalf("install: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
for _, path := range []string{bashrc, filepath.Join(f.home, ".zshrc"), fish} {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Count(string(data), "# >>> netssh >>>") != 1 {
|
||||||
|
t.Fatalf("bad managed blocks: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(bashrc)
|
||||||
|
for _, kept := range []string{"export KEEP=yes", "# netssh shell-init is documented here", "echo 'netssh shell-init'"} {
|
||||||
|
if !strings.Contains(string(data), kept) {
|
||||||
|
t.Fatalf("unrelated line lost: %s", kept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "alias old=") || strings.Contains(string(data), "eval ") {
|
||||||
|
t.Fatalf("legacy entries remain: %s", data)
|
||||||
|
}
|
||||||
|
if info, err := os.Lstat(bashrc); err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||||
|
t.Fatalf("symlink lost: %v", err)
|
||||||
|
}
|
||||||
|
if info, _ := os.Stat(target); info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatal("permissions changed")
|
||||||
|
}
|
||||||
|
backups, _ := filepath.Glob(target + ".netssh-backup.*")
|
||||||
|
if len(backups) != 1 {
|
||||||
|
t.Fatalf("backups: %v", backups)
|
||||||
|
}
|
||||||
|
backup, _ := os.ReadFile(backups[0])
|
||||||
|
if string(backup) != original {
|
||||||
|
t.Fatal("backup lost original content")
|
||||||
|
}
|
||||||
|
before := string(data)
|
||||||
|
if out, err := f.run(t); err != nil {
|
||||||
|
t.Fatalf("second install: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
data, _ = os.ReadFile(bashrc)
|
||||||
|
if string(data) != before {
|
||||||
|
t.Fatalf("not idempotent:\n%s", data)
|
||||||
|
}
|
||||||
|
backups, _ = filepath.Glob(target + ".netssh-backup.*")
|
||||||
|
if len(backups) != 1 {
|
||||||
|
t.Fatal("unchanged profile backed up again")
|
||||||
|
}
|
||||||
|
// Execute the generated Bash block to verify paths containing quotes and spaces.
|
||||||
|
cmd := exec.Command("bash", "-c", `. "$HOME/.bashrc"; alias j`)
|
||||||
|
cmd.Env = f.env
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil || !strings.Contains(string(out), "alias j='netssh'") {
|
||||||
|
t.Fatalf("source: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerRejectsUnverifiedDownloads(t *testing.T) {
|
||||||
|
for _, mode := range []string{"missing", "mismatch", "no-entry", "duplicate"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
f.env = append(f.env, "CHECKSUM_MODE="+mode)
|
||||||
|
sums := filepath.Join(f.assets, "checksums.txt")
|
||||||
|
switch mode {
|
||||||
|
case "mismatch":
|
||||||
|
writeFixture(t, sums, strings.Repeat("0", 64)+" netssh_linux_amd64\n", 0o644)
|
||||||
|
case "no-entry":
|
||||||
|
writeFixture(t, sums, strings.Repeat("0", 64)+" other_binary\n", 0o644)
|
||||||
|
case "duplicate":
|
||||||
|
data, _ := os.ReadFile(sums)
|
||||||
|
writeFixture(t, sums, string(data)+string(data), 0o644)
|
||||||
|
}
|
||||||
|
installed := filepath.Join(f.home, "local bin's", "netssh")
|
||||||
|
writeFixture(t, installed, "old binary", 0o755)
|
||||||
|
profile := filepath.Join(f.home, ".bashrc")
|
||||||
|
writeFixture(t, profile, "old profile\n", 0o600)
|
||||||
|
if out, err := f.run(t); err == nil {
|
||||||
|
t.Fatalf("accepted invalid checksum: %s", out)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(installed)
|
||||||
|
if string(data) != "old binary" {
|
||||||
|
t.Fatal("binary changed")
|
||||||
|
}
|
||||||
|
data, _ = os.ReadFile(profile)
|
||||||
|
if string(data) != "old profile\n" {
|
||||||
|
t.Fatal("profile changed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerRefusesBrokenManagedBlock(t *testing.T) {
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
profile := filepath.Join(f.home, ".bashrc")
|
||||||
|
original := "export KEEP=yes\n# >>> netssh >>>\nunfinished\n"
|
||||||
|
writeFixture(t, profile, original, 0o644)
|
||||||
|
if out, err := f.run(t); err == nil {
|
||||||
|
t.Fatalf("accepted broken block: %s", out)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(profile)
|
||||||
|
if string(data) != original {
|
||||||
|
t.Fatal("profile changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerUsesShasumAndFindsExistingBinary(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("shasum"); err != nil {
|
||||||
|
t.Skip("shasum not available")
|
||||||
|
}
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
// Hide sha256sum (and Python) as on a standard macOS installation.
|
||||||
|
for _, name := range []string{"shasum", "sed", "awk", "mktemp", "rm", "dirname", "readlink", "mkdir", "cat", "install", "mv", "cp", "chmod", "cmp", "perl"} {
|
||||||
|
tool, err := exec.LookPath(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(tool, filepath.Join(f.bin, name)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.env = append(f.env, "PATH="+f.bin, "INSTALL_DIR=")
|
||||||
|
installed := filepath.Join(f.bin, "netssh")
|
||||||
|
writeFixture(t, installed, "#!/bin/sh\nexit 1\n", 0o755)
|
||||||
|
if out, err := f.run(t); err != nil {
|
||||||
|
t.Fatalf("shasum update: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(installed)
|
||||||
|
expected, _ := os.ReadFile(filepath.Join(f.assets, "netssh_linux_amd64"))
|
||||||
|
if string(data) != string(expected) {
|
||||||
|
t.Fatal("existing installation was not replaced")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerRespectsZdotdir(t *testing.T) {
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
zshDir := filepath.Join(f.home, "custom-zsh")
|
||||||
|
f.env = append(f.env, "SHELL=/bin/zsh", "ZDOTDIR="+zshDir)
|
||||||
|
old := filepath.Join(f.home, ".zshrc")
|
||||||
|
writeFixture(t, old, "export KEEP=yes\nnetssh shell-init\n", 0o644)
|
||||||
|
if out, err := f.run(t); err != nil {
|
||||||
|
t.Fatalf("zsh setup: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(filepath.Join(zshDir, ".zshrc"))
|
||||||
|
if strings.Count(string(data), "# >>> netssh >>>") != 1 {
|
||||||
|
t.Fatalf("missing custom zsh setup: %s", data)
|
||||||
|
}
|
||||||
|
data, _ = os.ReadFile(old)
|
||||||
|
if string(data) != "export KEEP=yes\n" {
|
||||||
|
t.Fatalf("old zsh setup not cleaned: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerUsesMacOSCacheLayout(t *testing.T) {
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
writeFixture(t, filepath.Join(f.bin, "uname"), "#!/bin/sh\ncase \"$1\" in -s) echo Darwin;; -m) echo x86_64;; esac\n", 0o755)
|
||||||
|
data, _ := os.ReadFile(filepath.Join(f.assets, "netssh_linux_amd64"))
|
||||||
|
writeFixture(t, filepath.Join(f.assets, "checksums.txt"), fmt.Sprintf("%x netssh_darwin_amd64\n", sha256.Sum256(data)), 0o644)
|
||||||
|
f.env = append(f.env, "XDG_CACHE_HOME=")
|
||||||
|
if out, err := f.run(t); err != nil {
|
||||||
|
t.Fatalf("macOS setup: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(f.home, "Library", "Caches", "netssh", "aliases.sh")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerChecksDependenciesBeforeDownloading(t *testing.T) {
|
||||||
|
for _, missing := range [][]string{{"awk", "cmp"}, {"curl"}, {"sha256sum", "shasum"}} {
|
||||||
|
t.Run(strings.Join(missing, "+"), func(t *testing.T) {
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
// Build an isolated PATH containing every dependency except the selected tools.
|
||||||
|
for _, name := range []string{"curl", "uname", "sed", "awk", "mktemp", "rm", "dirname", "readlink", "mkdir", "cat", "install", "mv", "cp", "chmod", "cmp", "sha256sum"} {
|
||||||
|
skip := false
|
||||||
|
for _, absent := range missing {
|
||||||
|
if name == absent {
|
||||||
|
skip = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skip {
|
||||||
|
if err := os.Remove(filepath.Join(f.bin, name)); err != nil && !os.IsNotExist(err) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(f.bin, name)); err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path, err := exec.LookPath(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(path, filepath.Join(f.bin, name)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
marker := filepath.Join(f.assets, "curl-was-called")
|
||||||
|
// Replacing curl with a marker makes any premature network operation observable.
|
||||||
|
if missing[0] != "curl" {
|
||||||
|
writeFixture(t, filepath.Join(f.bin, "curl"), "#!/bin/bash\nprintf called > \"$FIXTURE_ASSETS/curl-was-called\"\nexit 99\n", 0o755)
|
||||||
|
}
|
||||||
|
f.env = append(f.env, "PATH="+f.bin)
|
||||||
|
installed := filepath.Join(f.home, "local bin's", "netssh")
|
||||||
|
writeFixture(t, installed, "old binary", 0o755)
|
||||||
|
out, err := f.run(t)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("missing dependencies accepted: %s", out)
|
||||||
|
}
|
||||||
|
for _, name := range missing {
|
||||||
|
if !strings.Contains(out, name) {
|
||||||
|
t.Errorf("missing diagnostic for %s: %s", name, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("curl ran before dependency checks completed")
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(installed)
|
||||||
|
if string(data) != "old binary" {
|
||||||
|
t.Fatal("binary changed")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(f.home, ".bashrc")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("profile created")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerChecksSudoBeforeDownloadingWhenNeeded(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("root can write directories regardless of mode bits")
|
||||||
|
}
|
||||||
|
f := newInstallerFixture(t)
|
||||||
|
for _, name := range []string{"sed", "awk", "mktemp", "rm", "dirname", "readlink", "mkdir", "cat", "install", "mv", "cp", "chmod", "cmp", "sha256sum"} {
|
||||||
|
path, err := exec.LookPath(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(path, filepath.Join(f.bin, name)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
locked := filepath.Join(f.home, "locked")
|
||||||
|
if err := os.Mkdir(locked, 0o555); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.Chmod(locked, 0o755) })
|
||||||
|
writeFixture(t, filepath.Join(f.bin, "curl"), "#!/bin/bash\nprintf called > \"$FIXTURE_ASSETS/curl-was-called\"\nexit 99\n", 0o755)
|
||||||
|
f.env = append(f.env, "PATH="+f.bin, "INSTALL_DIR="+filepath.Join(locked, "bin"))
|
||||||
|
out, err := f.run(t)
|
||||||
|
if err == nil || !strings.Contains(out, "sudo") {
|
||||||
|
t.Fatalf("missing sudo diagnostic: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(f.assets, "curl-was-called")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("download ran before sudo check")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
Shell ShellConfig `mapstructure:"shell"`
|
||||||
NetBox NetBoxConfig `mapstructure:"netbox"`
|
NetBox NetBoxConfig `mapstructure:"netbox"`
|
||||||
Resolver ResolverConfig `mapstructure:"resolver"`
|
Resolver ResolverConfig `mapstructure:"resolver"`
|
||||||
Cache CacheConfig `mapstructure:"cache"`
|
Cache CacheConfig `mapstructure:"cache"`
|
||||||
@@ -17,6 +19,10 @@ type Config struct {
|
|||||||
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ShellConfig struct {
|
||||||
|
Alias string `mapstructure:"alias"`
|
||||||
|
}
|
||||||
|
|
||||||
type NetBoxConfig struct {
|
type NetBoxConfig struct {
|
||||||
URL string `mapstructure:"url"`
|
URL string `mapstructure:"url"`
|
||||||
Token string `mapstructure:"token"`
|
Token string `mapstructure:"token"`
|
||||||
@@ -71,7 +77,7 @@ func Load() (*Config, error) {
|
|||||||
v.SetEnvPrefix("NETSSH")
|
v.SetEnvPrefix("NETSSH")
|
||||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
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"} {
|
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 {
|
if err := v.BindEnv(key); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -88,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
|
||||||
@@ -106,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
|
||||||
|
}
|
||||||
@@ -157,3 +157,23 @@ func TestEnvironmentOverridesWithoutConfigKeys(t *testing.T) {
|
|||||||
t.Fatalf("env ignored: %+v", cfg)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
-16
@@ -4,7 +4,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"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.
|
// AliasEntry holds a single shell alias mapping.
|
||||||
@@ -15,21 +22,21 @@ type AliasEntry struct {
|
|||||||
|
|
||||||
// AliasesPath returns the path for the bash/zsh aliases file.
|
// AliasesPath returns the path for the bash/zsh aliases file.
|
||||||
func AliasesPath() string {
|
func AliasesPath() string {
|
||||||
cacheDir, err := os.UserCacheDir()
|
cacheDir := os.Getenv("XDG_CACHE_HOME")
|
||||||
|
if cacheDir == "" {
|
||||||
|
var err error
|
||||||
|
cacheDir, err = os.UserCacheDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cacheDir = filepath.Join(os.Getenv("HOME"), ".cache")
|
cacheDir = filepath.Join(os.Getenv("HOME"), ".cache")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return filepath.Join(cacheDir, "netssh", "aliases.sh")
|
return filepath.Join(cacheDir, "netssh", "aliases.sh")
|
||||||
}
|
}
|
||||||
|
|
||||||
// FishAliasesPath returns the path for the fish conf.d aliases file.
|
// 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.
|
// Fish auto-sources every file in conf.d, so no explicit source line is needed.
|
||||||
func FishAliasesPath() string {
|
func FishAliasesPath() string {
|
||||||
configDir, err := os.UserConfigDir()
|
return filepath.Join(FishConfigDir(), "conf.d", "netssh.fish")
|
||||||
if err != nil {
|
|
||||||
configDir = filepath.Join(os.Getenv("HOME"), ".config")
|
|
||||||
}
|
|
||||||
return filepath.Join(configDir, "fish", "conf.d", "netssh.fish")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteAliasFiles writes alias definitions to disk for all supported shells.
|
// WriteAliasFiles writes alias definitions to disk for all supported shells.
|
||||||
@@ -42,7 +49,14 @@ func WriteAliasFiles(entries []AliasEntry) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
writeFishAliasFile(entries) // best-effort — never blocks the caller on failure
|
writeFishAliasFile(entries) // best-effort — never blocks the caller on failure
|
||||||
return nil
|
alias := ""
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Host == "" {
|
||||||
|
alias = entry.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SyncCommandCompletion(alias)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeShAliasFile(entries []AliasEntry) error {
|
func writeShAliasFile(entries []AliasEntry) error {
|
||||||
@@ -54,18 +68,14 @@ func writeShAliasFile(entries []AliasEntry) error {
|
|||||||
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
|
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
|
||||||
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
|
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
fmt.Fprintf(&b, "alias %s='netssh %s'\n", e.Name, e.Host)
|
b.WriteString(e.Definition("bash") + "\n")
|
||||||
}
|
}
|
||||||
return os.WriteFile(p, []byte(b.String()), 0o644)
|
return storage.WriteFile(p, []byte(b.String()), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeFishAliasFile(entries []AliasEntry) {
|
func writeFishAliasFile(entries []AliasEntry) {
|
||||||
configDir, err := os.UserConfigDir()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Only write if fish is configured on this system.
|
// Only write if fish is configured on this system.
|
||||||
if _, err := os.Stat(filepath.Join(configDir, "fish")); err != nil {
|
if _, err := os.Stat(FishConfigDir()); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p := FishAliasesPath()
|
p := FishAliasesPath()
|
||||||
@@ -76,7 +86,60 @@ func writeFishAliasFile(entries []AliasEntry) {
|
|||||||
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
|
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
|
||||||
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
|
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
fmt.Fprintf(&b, "alias %s 'netssh %s'\n", e.Name, e.Host)
|
b.WriteString(e.Definition("fish") + "\n")
|
||||||
}
|
}
|
||||||
os.WriteFile(p, []byte(b.String()), 0o644) //nolint:errcheck
|
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")
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -208,3 +210,20 @@ func TestUninstall_AlsoRemovesAliasesSourceLine(t *testing.T) {
|
|||||||
t.Error("other content should be preserved")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package hook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
const completionMarker = "# netssh command alias completion — generated automatically"
|
||||||
|
|
||||||
|
// SyncCommandCompletion installs lazy loaders for the configured command alias.
|
||||||
|
// The loaders obtain the canonical completion from the current netssh binary,
|
||||||
|
// so aliases configured by the wizard work without a separate completion install.
|
||||||
|
func SyncCommandCompletion(alias string) error {
|
||||||
|
if err := config.ValidateCommandAlias(alias); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
statePath := filepath.Join(filepath.Dir(AliasesPath()), "completion_alias")
|
||||||
|
data, err := os.ReadFile(statePath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
previous := strings.TrimSpace(string(data))
|
||||||
|
if err := config.ValidateCommandAlias(previous); err != nil {
|
||||||
|
return fmt.Errorf("invalid completion alias state: %w", err)
|
||||||
|
}
|
||||||
|
if alias == "" && previous == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||||
|
if previous != "" && previous != alias {
|
||||||
|
path := commandCompletionPath(shell, previous)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Only delete files owned by netssh; preserve any user replacements.
|
||||||
|
if strings.HasPrefix(string(data), completionMarker+"\n") || strings.HasPrefix(string(data), "#compdef "+previous+"\n"+completionMarker+"\n") {
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alias == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if shell == "fish" {
|
||||||
|
if _, err := os.Stat(FishConfigDir()); os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path := commandCompletionPath(shell, alias)
|
||||||
|
script := commandCompletionScript(shell, alias)
|
||||||
|
existing, err := os.ReadFile(path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if string(existing) == script {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := storage.WriteFile(path, []byte(script), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("writing %s alias completion: %w", shell, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alias == "" {
|
||||||
|
if err := os.Remove(statePath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return storage.WriteFile(statePath, []byte(alias+"\n"), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandCompletionPath(shell, alias string) string {
|
||||||
|
switch shell {
|
||||||
|
case "bash":
|
||||||
|
return filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", alias)
|
||||||
|
case "zsh":
|
||||||
|
return filepath.Join(os.Getenv("HOME"), ".zfunc", "_"+alias)
|
||||||
|
default:
|
||||||
|
return filepath.Join(FishConfigDir(), "completions", alias+".fish")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandCompletionScript(shell, alias string) string {
|
||||||
|
switch shell {
|
||||||
|
case "bash":
|
||||||
|
return fmt.Sprintf(`%s
|
||||||
|
source <(netssh completion bash)
|
||||||
|
if [[ $(type -t compopt) == builtin ]]; then
|
||||||
|
complete -o default -F __start_netssh %s
|
||||||
|
else
|
||||||
|
complete -o default -o nospace -F __start_netssh %s
|
||||||
|
fi
|
||||||
|
`, completionMarker, alias, alias)
|
||||||
|
case "zsh":
|
||||||
|
return fmt.Sprintf(`#compdef %s
|
||||||
|
%s
|
||||||
|
source <(netssh completion zsh)
|
||||||
|
compdef _netssh %s
|
||||||
|
_netssh "$@"
|
||||||
|
`, alias, completionMarker, alias)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf(`%s
|
||||||
|
netssh completion fish | source
|
||||||
|
complete --command %s --wraps netssh
|
||||||
|
`, completionMarker, alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package hook_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func completionEnvironment(t *testing.T) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config"))
|
||||||
|
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache"))
|
||||||
|
if err := os.MkdirAll(hook.FishConfigDir(), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return map[string]string{
|
||||||
|
"bash": filepath.Join(home, ".local", "share", "bash-completion", "completions", "j"),
|
||||||
|
"zsh": filepath.Join(home, ".zfunc", "_j"),
|
||||||
|
"fish": filepath.Join(hook.FishConfigDir(), "completions", "j.fish"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandCompletionTracksAliasChanges(t *testing.T) {
|
||||||
|
paths := completionEnvironment(t)
|
||||||
|
if err := hook.WriteAliasFiles([]hook.AliasEntry{{Name: "j"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A manually replaced completion file must survive removal of the old alias.
|
||||||
|
custom := "# user-owned completion\n"
|
||||||
|
if err := os.WriteFile(paths["bash"], []byte(custom), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := hook.WriteAliasFiles([]hook.AliasEntry{{Name: "k"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for shell, path := range paths {
|
||||||
|
if shell == "bash" {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil || string(data) != custom {
|
||||||
|
t.Fatal("user completion was removed")
|
||||||
|
}
|
||||||
|
} else if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("stale %s completion remains: %v", shell, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := hook.WriteAliasFiles(nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{
|
||||||
|
filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", "k"),
|
||||||
|
filepath.Join(os.Getenv("HOME"), ".zfunc", "_k"),
|
||||||
|
filepath.Join(hook.FishConfigDir(), "completions", "k.fish"),
|
||||||
|
} {
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("disabled completion remains: %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandAliasCompletesServerNamesAcrossShells(t *testing.T) {
|
||||||
|
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||||
|
t.Run(shell, func(t *testing.T) {
|
||||||
|
executable, err := exec.LookPath(shell)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("%s unavailable", shell)
|
||||||
|
}
|
||||||
|
paths := completionEnvironment(t)
|
||||||
|
if err := hook.SyncCommandCompletion("j"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fixtures := t.TempDir()
|
||||||
|
root := &cobra.Command{Use: "netssh"}
|
||||||
|
var canonical bytes.Buffer
|
||||||
|
switch shell {
|
||||||
|
case "bash":
|
||||||
|
err = root.GenBashCompletionV2(&canonical, true)
|
||||||
|
case "zsh":
|
||||||
|
err = root.GenZshCompletion(&canonical)
|
||||||
|
case "fish":
|
||||||
|
err = root.GenFishCompletion(&canonical, true)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(fixtures, shell), canonical.Bytes(), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Exercise Cobra's actual generated shell code with a deterministic backend.
|
||||||
|
backend := `#!/bin/bash
|
||||||
|
case "$1" in
|
||||||
|
completion) cat "$COMPLETION_FIXTURES/$2" ;;
|
||||||
|
__complete|__completeNoDesc) printf 'server01\n:4\n' ;;
|
||||||
|
*) exit 1 ;;
|
||||||
|
esac
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(fixtures, "netssh"), []byte(backend), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("COMPLETION_FIXTURES", fixtures)
|
||||||
|
t.Setenv("ALIAS_COMPLETION", paths[shell])
|
||||||
|
t.Setenv("PATH", fixtures+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
var script string
|
||||||
|
var args []string
|
||||||
|
switch shell {
|
||||||
|
case "bash":
|
||||||
|
library := ""
|
||||||
|
for _, path := range []string{"/usr/share/bash-completion/bash_completion", "/etc/bash_completion"} {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
library = path
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if library == "" {
|
||||||
|
t.Skip("bash-completion unavailable")
|
||||||
|
}
|
||||||
|
t.Setenv("BASH_COMPLETION_LIBRARY", library)
|
||||||
|
args = []string{"--noprofile", "--norc", "-c"}
|
||||||
|
script = `source "$BASH_COMPLETION_LIBRARY"
|
||||||
|
shopt -s expand_aliases
|
||||||
|
alias j=netssh
|
||||||
|
_completion_loader j
|
||||||
|
COMP_WORDS=(j ser)
|
||||||
|
COMP_CWORD=1
|
||||||
|
COMP_LINE='j ser'
|
||||||
|
COMP_POINT=5
|
||||||
|
COMP_TYPE=9
|
||||||
|
COMP_KEY=9
|
||||||
|
__start_netssh j ser j
|
||||||
|
printf '%s\n' "${COMPREPLY[@]}"
|
||||||
|
`
|
||||||
|
case "zsh":
|
||||||
|
args = []string{"-f", "-c"}
|
||||||
|
script = `fpath=("$HOME/.zfunc" $fpath)
|
||||||
|
autoload -Uz compinit
|
||||||
|
compinit -u -D
|
||||||
|
alias j=netssh
|
||||||
|
autoload -Uz _j
|
||||||
|
# Capture matches without requiring an interactive ZLE completion widget.
|
||||||
|
_describe() { print -rl -- "${completions[@]}"; }
|
||||||
|
words=(j ser)
|
||||||
|
CURRENT=2
|
||||||
|
_j
|
||||||
|
`
|
||||||
|
case "fish":
|
||||||
|
args = []string{"--no-config", "-c"}
|
||||||
|
script = `alias j netssh
|
||||||
|
set -p fish_complete_path "$XDG_CONFIG_HOME/fish/completions"
|
||||||
|
complete --do-complete 'j ser'
|
||||||
|
`
|
||||||
|
}
|
||||||
|
cmd := exec.Command(executable, append(args, script)...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil || !strings.Contains(string(out), "server01") {
|
||||||
|
t.Fatalf("%s completion: %v\n%s", shell, err, out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
-22
@@ -4,12 +4,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Marker is the unique string used to detect an existing hook line.
|
// Marker is the unique string used to detect an existing hook line.
|
||||||
Marker = "netssh shell-init"
|
Marker = "netssh shell-init"
|
||||||
|
BlockStart = "# >>> netssh >>>"
|
||||||
|
BlockEnd = "# <<< netssh <<<"
|
||||||
// Line is the full line written into the shell profile.
|
// Line is the full line written into the shell profile.
|
||||||
Line = "netssh shell-init # netssh cache auto-refresh"
|
Line = "netssh shell-init # netssh cache auto-refresh"
|
||||||
|
|
||||||
@@ -25,13 +30,13 @@ func ProfilePath(shell string) (string, error) {
|
|||||||
case "bash":
|
case "bash":
|
||||||
return filepath.Join(os.Getenv("HOME"), ".bashrc"), nil
|
return filepath.Join(os.Getenv("HOME"), ".bashrc"), nil
|
||||||
case "zsh":
|
case "zsh":
|
||||||
return filepath.Join(os.Getenv("HOME"), ".zshrc"), nil
|
dir := os.Getenv("ZDOTDIR")
|
||||||
case "fish":
|
if dir == "" {
|
||||||
configDir, err := os.UserConfigDir()
|
dir = os.Getenv("HOME")
|
||||||
if err != nil {
|
|
||||||
configDir = filepath.Join(os.Getenv("HOME"), ".config")
|
|
||||||
}
|
}
|
||||||
return filepath.Join(configDir, "fish", "config.fish"), nil
|
return filepath.Join(dir, ".zshrc"), nil
|
||||||
|
case "fish":
|
||||||
|
return filepath.Join(FishConfigDir(), "config.fish"), nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unsupported shell %q — supported: bash, zsh, fish", shell)
|
return "", fmt.Errorf("unsupported shell %q — supported: bash, zsh, fish", shell)
|
||||||
}
|
}
|
||||||
@@ -49,7 +54,12 @@ func IsInstalled(profilePath string) bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return strings.Contains(string(data), Marker)
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.TrimSpace(line) == BlockStart || hookLinePattern.MatchString(strings.TrimSpace(line)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install appends the hook line to the profile if it is not already present.
|
// Install appends the hook line to the profile if it is not already present.
|
||||||
@@ -81,23 +91,27 @@ func InstallAliasesSource(profilePath string) (installed bool, err error) {
|
|||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return false, fmt.Errorf("reading profile: %w", err)
|
return false, fmt.Errorf("reading profile: %w", err)
|
||||||
}
|
}
|
||||||
if strings.Contains(string(data), AliasesMarker) {
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.TrimSpace(line) == BlockStart || aliasesLinePattern.MatchString(strings.TrimSpace(line)) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("opening profile: %w", err)
|
return false, fmt.Errorf("opening profile: %w", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
if _, err := fmt.Fprintf(f, "%s\n", AliasesSourceLine); err != nil {
|
if _, err := fmt.Fprintf(f, "%s\n", "source "+shellQuote(AliasesPath(), "bash")+" 2>/dev/null # netssh aliases"); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uninstall removes the hook line and the aliases source line from the profile.
|
var hookLinePattern = regexp.MustCompile(`^(?:[^\s]*/)?netssh\s+shell-init\s*(?:#.*)?$`)
|
||||||
// Returns (true, nil) when something was removed, (false, nil) when nothing was found.
|
var aliasesLinePattern = regexp.MustCompile(`^(?:source|\.)\s+["']?[^;|&]*netssh/aliases\.sh["']?(?:\s+2>/dev/null)?\s*(?:#.*)?$`)
|
||||||
// Returns (false, nil) — not an error — when the file does not exist.
|
|
||||||
|
// Uninstall removes legacy hook lines and complete installer-managed blocks.
|
||||||
|
// Missing profiles are a no-op; malformed blocks leave the file unchanged.
|
||||||
func Uninstall(profilePath string) (removed bool, err error) {
|
func Uninstall(profilePath string) (removed bool, err error) {
|
||||||
data, err := os.ReadFile(profilePath)
|
data, err := os.ReadFile(profilePath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -106,22 +120,50 @@ func Uninstall(profilePath string) (removed bool, err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("reading profile: %w", err)
|
return false, fmt.Errorf("reading profile: %w", err)
|
||||||
}
|
}
|
||||||
content := string(data)
|
|
||||||
if !strings.Contains(content, Marker) && !strings.Contains(content, AliasesMarker) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var kept []string
|
var kept []string
|
||||||
for _, line := range strings.Split(content, "\n") {
|
inside := false
|
||||||
if strings.Contains(line, Marker) || strings.Contains(line, AliasesMarker) {
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
value := strings.TrimSpace(line)
|
||||||
|
switch value {
|
||||||
|
case BlockStart:
|
||||||
|
if inside {
|
||||||
|
return false, fmt.Errorf("nested netssh block in %s", profilePath)
|
||||||
|
}
|
||||||
|
inside = true
|
||||||
|
removed = true
|
||||||
|
continue
|
||||||
|
case BlockEnd:
|
||||||
|
if !inside {
|
||||||
|
return false, fmt.Errorf("unmatched netssh block in %s", profilePath)
|
||||||
|
}
|
||||||
|
inside = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inside {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hookLinePattern.MatchString(value) || aliasesLinePattern.MatchString(value) {
|
||||||
|
removed = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
kept = append(kept, line)
|
kept = append(kept, line)
|
||||||
}
|
}
|
||||||
// Collapse triple blank lines that the removal may leave.
|
if inside {
|
||||||
|
return false, fmt.Errorf("unclosed netssh block in %s", profilePath)
|
||||||
|
}
|
||||||
|
if !removed {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
cleaned := strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n")
|
cleaned := strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n")
|
||||||
|
target, err := filepath.EvalSymlinks(profilePath)
|
||||||
if err := os.WriteFile(profilePath, []byte(cleaned), 0o644); err != nil {
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
info, err := os.Stat(target)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := storage.WriteFile(target, []byte(cleaned), info.Mode().Perm()); err != nil {
|
||||||
return false, fmt.Errorf("writing profile: %w", err)
|
return false, fmt.Errorf("writing profile: %w", err)
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
|
|||||||
@@ -261,3 +261,30 @@ func writeProfile(t *testing.T, content string) string {
|
|||||||
}
|
}
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManagedBlockInstallAndUninstall(t *testing.T) {
|
||||||
|
original := "export KEEP=yes\n# netssh shell-init documentation\necho 'netssh shell-init'\n"
|
||||||
|
profile := writeProfile(t, original+hook.BlockStart+"\nexport PATH='/custom/bin':\"$PATH\"\n'/custom/bin/netssh' shell-init\n[ ! -f '/custom/cache/netssh/aliases.sh' ] || . '/custom/cache/netssh/aliases.sh'\n"+hook.BlockEnd+"\n")
|
||||||
|
if installed, err := hook.Install(profile); err != nil || installed {
|
||||||
|
t.Fatalf("duplicate install: %v, %v", installed, err)
|
||||||
|
}
|
||||||
|
if installed, err := hook.InstallAliasesSource(profile); err != nil || installed {
|
||||||
|
t.Fatalf("duplicate source: %v, %v", installed, err)
|
||||||
|
}
|
||||||
|
if removed, err := hook.Uninstall(profile); err != nil || !removed {
|
||||||
|
t.Fatalf("uninstall: %v, %v", removed, err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(profile)
|
||||||
|
if string(data) != original {
|
||||||
|
t.Fatalf("unrelated content changed: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfilePathRespectsZdotdir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("ZDOTDIR", dir)
|
||||||
|
path, err := hook.ProfilePath("zsh")
|
||||||
|
if err != nil || path != filepath.Join(dir, ".zshrc") {
|
||||||
|
t.Fatalf("zsh path: %s, %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ func TestLocalNotesPersistAndStayIsolated(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
s := &Service{Dir: t.TempDir(), Namespace: "https://netbox.example"}
|
s := &Service{Dir: t.TempDir(), Namespace: "https://netbox.example"}
|
||||||
host := netbox.HostEntry{Name: "web01", Kind: "device"}
|
host := netbox.HostEntry{Name: "web01", Kind: "device"}
|
||||||
n := Note{Host: host, Text: "Wartung\nGrüße 🔧"}
|
n := Note{Host: host, Text: "Maintenance\nReady 🔧"}
|
||||||
if err := s.Write(ctx, n, false); err != nil {
|
if err := s.Write(ctx, n, false); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
|
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
|
||||||
@@ -30,6 +33,7 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
|
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
|
||||||
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
|
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
|
||||||
stripHyphens := cfg.Shortcuts.StripHyphens
|
stripHyphens := cfg.Shortcuts.StripHyphens
|
||||||
|
commandAlias := cfg.Shell.Alias
|
||||||
|
|
||||||
if strategiesRaw == "" {
|
if strategiesRaw == "" {
|
||||||
strategiesRaw = "primary_ip"
|
strategiesRaw = "primary_ip"
|
||||||
@@ -122,6 +126,12 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
Title("Strip hyphens").
|
Title("Strip hyphens").
|
||||||
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
|
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
|
||||||
Value(&stripHyphens),
|
Value(&stripHyphens),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Command alias").
|
||||||
|
Description("Optional alias for netssh itself, e.g. j. Leave empty to disable.").
|
||||||
|
Placeholder("j").
|
||||||
|
Value(&commandAlias).
|
||||||
|
Validate(func(s string) error { return config.ValidateCommandAlias(strings.TrimSpace(s)) }),
|
||||||
).Title("Shortcuts"),
|
).Title("Shortcuts"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -136,7 +146,7 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
strategies := parseStrategies(strategiesRaw)
|
strategies := parseStrategies(strategiesRaw)
|
||||||
tokenVersion := netbox.TokenVersion(token)
|
tokenVersion := netbox.TokenVersion(token)
|
||||||
if tokenVersion == 1 {
|
if tokenVersion == 1 {
|
||||||
fmt.Fprintln(os.Stderr, "\nHinweis: Du verwendest einen Legacy-Token (v1). Erstelle in NetBox einen v2-Token (beginnt mit nbt_) für bessere Kompatibilität.")
|
fmt.Fprintln(os.Stderr, "\nNote: You are using a legacy token (v1). Create a v2 token in NetBox (starting with nbt_) for better compatibility.")
|
||||||
fmt.Fprintln(os.Stderr, " NetBox → Admin → API Tokens → Add Token")
|
fmt.Fprintln(os.Stderr, " NetBox → Admin → API Tokens → Add Token")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +175,7 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := config.Config{
|
out := config.Config{
|
||||||
|
Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)},
|
||||||
NetBox: config.NetBoxConfig{
|
NetBox: config.NetBoxConfig{
|
||||||
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
||||||
Token: strings.TrimSpace(token),
|
Token: strings.TrimSpace(token),
|
||||||
@@ -181,6 +192,7 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
},
|
},
|
||||||
Cache: config.CacheConfig{
|
Cache: config.CacheConfig{
|
||||||
TTL: ttl,
|
TTL: ttl,
|
||||||
|
Path: cfg.Cache.Path,
|
||||||
},
|
},
|
||||||
Shortcuts: config.ShortcutsConfig{
|
Shortcuts: config.ShortcutsConfig{
|
||||||
Domains: domainList,
|
Domains: domainList,
|
||||||
@@ -188,7 +200,38 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return save(out)
|
if err := save(out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
loaded, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c := cache.New(loaded.Cache.Path, loaded.Cache.TTL)
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
return fmt.Errorf("updating aliases: %w", err)
|
||||||
|
}
|
||||||
|
if err := hook.WriteAliasFiles(hook.BuildAliasEntries(c.All(), *loaded)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if loaded.Shell.Alias != "" {
|
||||||
|
shell := filepath.Base(os.Getenv("SHELL"))
|
||||||
|
profile, err := hook.ProfilePath(shell)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Run 'netssh hook install --shell bash|zsh|fish' to activate your alias.\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := hook.Install(profile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if shell != "fish" {
|
||||||
|
if _, err := hook.InstallAliasesSource(profile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, hook.ReloadNote(profile))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var knownStrategies = map[string]bool{
|
var knownStrategies = map[string]bool{
|
||||||
@@ -220,6 +263,9 @@ func parseStrategies(s string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func save(cfg config.Config) error {
|
func save(cfg config.Config) error {
|
||||||
|
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
path := config.Path()
|
path := config.Path()
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
return fmt.Errorf("creating config dir: %w", err)
|
return fmt.Errorf("creating config dir: %w", err)
|
||||||
@@ -248,6 +294,9 @@ func save(cfg config.Config) error {
|
|||||||
|
|
||||||
b.WriteString("\ncache:\n")
|
b.WriteString("\ncache:\n")
|
||||||
fmt.Fprintf(&b, " ttl: %d\n", cfg.Cache.TTL)
|
fmt.Fprintf(&b, " ttl: %d\n", cfg.Cache.TTL)
|
||||||
|
if cfg.Cache.Path != "" {
|
||||||
|
fmt.Fprintf(&b, " path: %q\n", cfg.Cache.Path)
|
||||||
|
}
|
||||||
|
|
||||||
b.WriteString("\nssh:\n")
|
b.WriteString("\nssh:\n")
|
||||||
if cfg.SSH.DefaultUser != "" {
|
if cfg.SSH.DefaultUser != "" {
|
||||||
@@ -270,7 +319,11 @@ func save(cfg config.Config) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
|
if cfg.Shell.Alias != "" {
|
||||||
|
fmt.Fprintf(&b, "\nshell:\n alias: %q\n", cfg.Shell.Alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.WriteFile(path, []byte(b.String()), 0o600); err != nil {
|
||||||
return fmt.Errorf("writing config: %w", err)
|
return fmt.Errorf("writing config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -366,3 +366,23 @@ func TestSave_RoundtripViaLoad(t *testing.T) {
|
|||||||
t.Errorf("TTL: got %d, want %d", loaded.Cache.TTL, original.Cache.TTL)
|
t.Errorf("TTL: got %d, want %d", loaded.Cache.TTL, original.Cache.TTL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveCommandAliasRoundtripAndDisable(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
cfg := config.Config{Shell: config.ShellConfig{Alias: "j"}, Cache: config.CacheConfig{Path: filepath.Join(t.TempDir(), "hosts.json")}}
|
||||||
|
if err := save(cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := config.Load()
|
||||||
|
if err != nil || loaded.Shell.Alias != "j" || loaded.Cache.Path != cfg.Cache.Path {
|
||||||
|
t.Fatalf("roundtrip: %+v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
cfg.Shell.Alias = ""
|
||||||
|
if err := save(cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err = config.Load()
|
||||||
|
if err != nil || loaded.Shell.Alias != "" {
|
||||||
|
t.Fatalf("disable: %+v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,7 +158,7 @@ type Model struct {
|
|||||||
height int
|
height int
|
||||||
recentMode bool // true when showing recent hosts (empty search, initial state)
|
recentMode bool // true when showing recent hosts (empty search, initial state)
|
||||||
filterOpen bool // Ctrl+F toggles
|
filterOpen bool // Ctrl+F toggles
|
||||||
editMode bool // 'e' on selected item
|
editMode bool // Ctrl+E on the selected item
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
|
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,5 +2,41 @@
|
|||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": [
|
"extends": [
|
||||||
"config:recommended"
|
"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"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Executable
+26
@@ -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
|
||||||
Reference in new issue
Block a user