Updates
ci / test (push) Successful in 33s
ci / release (push) Successful in 1m22s

This commit is contained in:
2026-07-03 06:13:54 -04:00
parent 6d28613ef5
commit b59cf4eb04
9 changed files with 675 additions and 12 deletions
+2 -1
View File
@@ -42,7 +42,8 @@ jobs:
bin="hp-viz-${os}-${arch}"
pkg="hp-viz-${os}-${arch}.tar.gz"
GOOS=$os GOARCH=$arch CGO_ENABLED=0 go build \
-ldflags="-s -w" -trimpath \
-ldflags="-s -w -X github.com/honeypeer/cli-viz/internal/update.BuildCommit=${GITHUB_SHA}" \
-trimpath \
-o "dist/${bin}" ./cmd/hp-viz/
tar -czf "dist/${pkg}" -C dist "${bin}"
sha256sum "dist/${pkg}" | awk '{print $1}' > "dist/${pkg}.sha256"
+14 -1
View File
@@ -84,7 +84,7 @@ Precedence: **flags → environment → config file → defaults**.
| `Tab` | Feed → Incidents → Peers → Stats → Blog |
| `Enter` | Open detail (or read blog post on Blog tab) |
| `Esc` | Close detail / back from blog post / cancel filter or search |
| `Ctrl+M` | Toggle mouse (off by default) |
| `m` | Toggle mouse (off by default) |
| `f` | Toggle follow mode (auto-scroll to newest) |
| `s` | Search by attack or moderation ID (opens detail on match) |
| `/` | Filter — text or `service:ssh geo:cn peer:id since:1h` |
@@ -94,6 +94,19 @@ Precedence: **flags → environment → config file → defaults**.
| `?` | Help overlay |
| `q` | Quit |
## Updates
Release builds check for a newer rolling build on startup (every 6 hours by default). When a new commit is published to the [latest release](https://git.ssh.surf/snxraven/honeypeer-viz-cli/releases/tag/latest), `hp-viz` downloads the matching archive, verifies SHA256, and replaces itself in place.
```bash
hp-viz --version # show embedded build commit
hp-viz --update # check now and apply if newer
hp-viz --no-update # skip automatic checks
HP_VIZ_NO_UPDATE=1 hp-viz # same via environment
```
Source builds (`go build` without release ldflags) are not auto-updated. Install via the curl installer to get a self-updating binary.
In the detail view, `j`/`k` or the mouse wheel scroll long content.
## What you see
+62
View File
@@ -3,6 +3,8 @@ package main
import (
"fmt"
"os"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -10,6 +12,7 @@ import (
"github.com/honeypeer/cli-viz/internal/config"
"github.com/honeypeer/cli-viz/internal/ui"
"github.com/honeypeer/cli-viz/internal/update"
)
func main() {
@@ -19,6 +22,21 @@ func main() {
os.Exit(1)
}
if cfg.Version {
fmt.Println(update.VersionString())
os.Exit(0)
}
if cfg.Update {
exitUpdate(true)
}
if !cfg.NoUpdate && cfg.Fixture == "" {
if msg := runAutoUpdate(); msg != "" {
fmt.Fprintln(os.Stderr, msg)
}
}
if cfg.NoColor {
os.Setenv("NO_COLOR", "1")
lipgloss.SetColorProfile(termenv.Ascii)
@@ -31,3 +49,47 @@ func main() {
os.Exit(1)
}
}
func runAutoUpdate() string {
opts := update.DefaultOptions()
if base := strings.TrimSpace(os.Getenv("HP_VIZ_RELEASE_BASE")); base != "" {
opts.ReleaseBase = strings.TrimRight(base, "/")
}
if h := config.EnvIntOr("HP_VIZ_UPDATE_INTERVAL_HOURS", 0); h > 0 {
opts.Interval = time.Duration(h) * time.Hour
}
res, err := update.MaybeCheck(opts)
if err != nil {
return fmt.Sprintf("hp-viz update check failed: %v", err)
}
if res.Updated {
return fmt.Sprintf("hp-viz updated to %s", res.CommitShort)
}
return ""
}
func exitUpdate(force bool) {
opts := update.DefaultOptions()
opts.Force = force
if base := strings.TrimSpace(os.Getenv("HP_VIZ_RELEASE_BASE")); base != "" {
opts.ReleaseBase = strings.TrimRight(base, "/")
}
res, err := update.MaybeCheck(opts)
if err != nil {
fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
os.Exit(1)
}
switch {
case res.Updated:
fmt.Printf("Updated to %s\n", res.CommitShort)
case res.Skipped == "already current":
fmt.Printf("Already up to date (%s)\n", res.CommitShort)
case res.Skipped == "source build":
fmt.Fprintln(os.Stderr, "This is a source build (no embedded commit). Install from the latest release to enable updates.")
os.Exit(1)
default:
fmt.Println(res.Skipped)
}
}
+33 -7
View File
@@ -19,6 +19,9 @@ type Config struct {
NoColor bool
Bell bool
Operator bool
NoUpdate bool
Version bool
Update bool
}
func Load() (Config, error) {
@@ -32,16 +35,22 @@ func Load() (Config, error) {
record := flag.String("record", "", "Append SSE events to JSONL file")
noColor := flag.Bool("no-color", false, "Disable truecolor output")
bell := flag.Bool("bell", false, "Terminal bell on block-tier events")
noUpdate := flag.Bool("no-update", envBoolOr("HP_VIZ_NO_UPDATE", false), "Disable automatic update checks")
showVersion := flag.Bool("version", false, "Print version and exit")
forceUpdate := flag.Bool("update", false, "Check for updates, apply if available, and exit")
flag.Parse()
cfg := Config{
URL: trimSlash(*url),
Secret: strings.TrimSpace(*secret),
Buffer: *buffer,
Fixture: *fixture,
Record: *record,
NoColor: *noColor,
Bell: *bell,
URL: trimSlash(*url),
Secret: strings.TrimSpace(*secret),
Buffer: *buffer,
Fixture: *fixture,
Record: *record,
NoColor: *noColor,
Bell: *bell,
NoUpdate: *noUpdate,
Version: *showVersion,
Update: *forceUpdate,
}
cfg.Operator = cfg.Secret != ""
@@ -73,6 +82,23 @@ func envIntOr(key string, fallback int) int {
return fallback
}
// EnvIntOr reads an integer environment variable.
func EnvIntOr(key string, fallback int) int {
return envIntOr(key, fallback)
}
func envBoolOr(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return fallback
}
func trimSlash(s string) string {
for len(s) > 0 && s[len(s)-1] == '/' {
s = s[:len(s)-1]
+2
View File
@@ -56,5 +56,7 @@ func setEnvIfEmpty(key, val string) {
_ = os.Setenv("HP_VIZ_BELL", val)
case "mouse":
_ = os.Setenv("HP_VIZ_MOUSE", val)
case "no_update":
_ = os.Setenv("HP_VIZ_NO_UPDATE", val)
}
}
+2 -2
View File
@@ -266,7 +266,7 @@ func renderFooter(t Theme, filtering, searching bool, filter string, follow bool
} else {
hints = []string{"Tab view", "j/k nav", "Enter detail", "s search", "/ filter", "? help", "q quit"}
}
hints = append(hints, "ctrl+m mouse")
hints = append(hints, "m mouse")
if filtering {
hints = []string{"type filter…", "Enter apply", "Esc cancel"}
}
@@ -304,7 +304,7 @@ func renderHelp(t Theme) string {
PgUp Load more posts (list view)
Mouse (off by default)
Ctrl+M Toggle mouse support
m Toggle mouse support
Click row Select row (when enabled)
Wheel Scroll list (when enabled)
Double-click Open detail (when enabled)
+1 -1
View File
@@ -74,7 +74,7 @@ func defaultKeyMap() keyMap {
Search: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "search")),
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "detail")),
Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close")),
Mouse: key.NewBinding(key.WithKeys("ctrl+m"), key.WithHelp("ctrl+m", "mouse")),
Mouse: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "mouse")),
Reconnect: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reconnect")),
PgUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "history")),
PgDn: key.NewBinding(key.WithKeys("pgdn"), key.WithHelp("pgdn", "down")),
+394
View File
@@ -0,0 +1,394 @@
package update
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
const (
DefaultReleaseBase = "https://git.ssh.surf/snxraven/honeypeer-viz-cli/releases/download/latest"
DefaultAPIURL = "https://git.ssh.surf/api/v1/repos/snxraven/honeypeer-viz-cli/releases/tags/latest"
DefaultInterval = 6 * time.Hour
stateFileName = "update-state.json"
)
// BuildCommit is set at link time for release binaries (-ldflags -X ...BuildCommit=...).
var BuildCommit string
type Options struct {
ReleaseBase string
APIURL string
Client *http.Client
StatePath string
Interval time.Duration
Force bool
}
type Result struct {
Updated bool
Commit string
CommitShort string
Skipped string
}
type releaseInfo struct {
TargetCommitish string `json:"target_commitish"`
}
type persistedState struct {
LastCheck time.Time `json:"last_check"`
LastCommit string `json:"last_commit"`
}
func DefaultOptions() Options {
statePath := ""
if dir, err := os.UserCacheDir(); err == nil {
statePath = filepath.Join(dir, "hp-viz", stateFileName)
}
return Options{
ReleaseBase: DefaultReleaseBase,
APIURL: DefaultAPIURL,
Client: &http.Client{Timeout: 2 * time.Minute},
StatePath: statePath,
Interval: DefaultInterval,
}
}
func VersionString() string {
commit := strings.TrimSpace(BuildCommit)
if commit == "" {
return "hp-viz dev (source build)"
}
return "hp-viz " + shortCommit(commit)
}
func CurrentCommit() string {
return normalizeCommit(BuildCommit)
}
// MaybeCheck checks for a newer rolling release and applies it when available.
func MaybeCheck(opts Options) (Result, error) {
opts = normalizeOptions(opts)
if !opts.Force && !isReleaseBuild() {
return Result{Skipped: "source build"}, nil
}
if !opts.Force && shouldSkipByInterval(opts) {
return Result{Skipped: "checked recently"}, nil
}
remote, err := fetchLatestRelease(opts)
if err != nil {
return Result{}, err
}
remoteCommit := normalizeCommit(remote.TargetCommitish)
localCommit := normalizeCommit(BuildCommit)
saveCheckTime(opts, remoteCommit)
if remoteCommit == "" {
return Result{}, fmt.Errorf("release has no target commit")
}
if remoteCommit == localCommit && localCommit != "" {
return Result{Skipped: "already current", Commit: remoteCommit, CommitShort: shortCommit(remoteCommit)}, nil
}
bin, err := downloadBinary(opts)
if err != nil {
return Result{}, err
}
exe, err := selfPath()
if err != nil {
return Result{}, err
}
if err := applyBinary(exe, bin); err != nil {
return Result{}, err
}
saveCheckTime(opts, remoteCommit)
return Result{
Updated: true,
Commit: remoteCommit,
CommitShort: shortCommit(remoteCommit),
}, nil
}
func isReleaseBuild() bool {
return normalizeCommit(BuildCommit) != ""
}
func normalizeOptions(opts Options) Options {
if opts.ReleaseBase == "" {
opts.ReleaseBase = DefaultReleaseBase
}
if opts.APIURL == "" {
opts.APIURL = DefaultAPIURL
}
if opts.Client == nil {
opts.Client = &http.Client{Timeout: 2 * time.Minute}
}
if opts.Interval <= 0 {
opts.Interval = DefaultInterval
}
if opts.StatePath == "" {
if dir, err := os.UserCacheDir(); err == nil {
opts.StatePath = filepath.Join(dir, "hp-viz", stateFileName)
}
}
return opts
}
func shouldSkipByInterval(opts Options) bool {
if opts.StatePath == "" {
return false
}
st, err := readState(opts.StatePath)
if err != nil {
return false
}
if st.LastCheck.IsZero() {
return false
}
local := normalizeCommit(BuildCommit)
if local != "" && st.LastCommit == local {
return time.Since(st.LastCheck) < opts.Interval
}
return false
}
func saveCheckTime(opts Options, commit string) {
if opts.StatePath == "" {
return
}
st, _ := readState(opts.StatePath)
st.LastCheck = time.Now()
if commit != "" {
st.LastCommit = commit
}
_ = writeState(opts.StatePath, st)
}
func fetchLatestRelease(opts Options) (releaseInfo, error) {
req, err := http.NewRequest(http.MethodGet, opts.APIURL, nil)
if err != nil {
return releaseInfo{}, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", userAgent())
resp, err := opts.Client.Do(req)
if err != nil {
return releaseInfo{}, fmt.Errorf("check release: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return releaseInfo{}, fmt.Errorf("check release: HTTP %d", resp.StatusCode)
}
var info releaseInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return releaseInfo{}, fmt.Errorf("parse release: %w", err)
}
return info, nil
}
func platformArtifact() (pkg, bin string, err error) {
osName := runtime.GOOS
arch := runtime.GOARCH
switch osName {
case "linux", "darwin":
default:
return "", "", fmt.Errorf("auto-update unsupported on %s", osName)
}
switch arch {
case "amd64", "arm64":
default:
return "", "", fmt.Errorf("auto-update unsupported on %s/%s", osName, arch)
}
pkg = fmt.Sprintf("hp-viz-%s-%s.tar.gz", osName, arch)
bin = fmt.Sprintf("hp-viz-%s-%s", osName, arch)
return pkg, bin, nil
}
func downloadBinary(opts Options) ([]byte, error) {
pkg, binName, err := platformArtifact()
if err != nil {
return nil, err
}
base := strings.TrimRight(opts.ReleaseBase, "/")
archiveURL := base + "/" + pkg
sumURL := archiveURL + ".sha256"
archive, err := downloadURL(opts.Client, archiveURL)
if err != nil {
return nil, fmt.Errorf("download archive: %w", err)
}
sumBytes, err := downloadURL(opts.Client, sumURL)
if err != nil {
return nil, fmt.Errorf("download checksum: %w", err)
}
expected := strings.Fields(strings.TrimSpace(string(sumBytes)))[0]
if err := verifySHA256(archive, expected); err != nil {
return nil, err
}
return extractBinary(archive, binName)
}
func downloadURL(client *http.Client, url string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent())
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 64<<20))
}
func verifySHA256(data []byte, expected string) error {
sum := sha256.Sum256(data)
actual := hex.EncodeToString(sum[:])
if !strings.EqualFold(actual, strings.TrimSpace(expected)) {
return fmt.Errorf("checksum mismatch")
}
return nil
}
func extractBinary(archive []byte, binName string) ([]byte, error) {
gr, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
return nil, err
}
defer gr.Close()
tr := tar.NewReader(gr)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if hdr.Typeflag != tar.TypeReg {
continue
}
if filepath.Base(hdr.Name) != binName {
continue
}
return io.ReadAll(io.LimitReader(tr, 64<<20))
}
return nil, fmt.Errorf("%s not found in archive", binName)
}
func selfPath() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
return filepath.EvalSymlinks(exe)
}
func applyBinary(dest string, data []byte) error {
dir := filepath.Dir(dest)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := probeWritable(dir); err != nil {
return fmt.Errorf("cannot write to %s: %w (re-run install or use sudo)", dir, err)
}
tmp, err := os.CreateTemp(dir, ".hp-viz-update-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(0o755); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, dest); err != nil {
return err
}
return nil
}
func probeWritable(dir string) error {
f, err := os.CreateTemp(dir, ".hp-viz-write-test-*")
if err != nil {
return err
}
name := f.Name()
f.Close()
return os.Remove(name)
}
func readState(path string) (persistedState, error) {
var st persistedState
data, err := os.ReadFile(path)
if err != nil {
return st, err
}
err = json.Unmarshal(data, &st)
return st, err
}
func writeState(path string, st persistedState) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.Marshal(st)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func normalizeCommit(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
return s
}
func shortCommit(s string) string {
s = normalizeCommit(s)
if len(s) <= 7 {
return s
}
return s[:7]
}
func userAgent() string {
c := shortCommit(BuildCommit)
if c == "" {
return "hp-viz/dev"
}
return "hp-viz/" + c
}
+165
View File
@@ -0,0 +1,165 @@
package update
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestMaybeCheckAlreadyCurrent(t *testing.T) {
BuildCommit = "abc1234567890deadbeef"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(releaseInfo{TargetCommitish: BuildCommit})
}))
defer srv.Close()
opts := DefaultOptions()
opts.APIURL = srv.URL
opts.Force = true
res, err := MaybeCheck(opts)
if err != nil {
t.Fatal(err)
}
if res.Updated {
t.Fatal("expected no update")
}
if res.Skipped != "already current" {
t.Fatalf("skipped = %q", res.Skipped)
}
}
func TestMaybeCheckSkipsSourceBuild(t *testing.T) {
BuildCommit = ""
res, err := MaybeCheck(DefaultOptions())
if err != nil {
t.Fatal(err)
}
if res.Skipped != "source build" {
t.Fatalf("skipped = %q", res.Skipped)
}
}
func TestDownloadAndExtractBinary(t *testing.T) {
binName := "hp-viz-linux-amd64"
payload := []byte("#!/bin/sh\necho hi\n")
archive := buildTestArchive(t, binName, payload)
sum := sha256.Sum256(archive)
sumHex := hex.EncodeToString(sum[:])
mux := http.NewServeMux()
mux.HandleFunc("/"+binName+".tar.gz", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(archive)
})
mux.HandleFunc("/"+binName+".tar.gz.sha256", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(sumHex + "\n"))
})
srv := httptest.NewServer(mux)
defer srv.Close()
opts := DefaultOptions()
opts.ReleaseBase = srv.URL
got, err := downloadBinaryWithPlatform(opts, "linux", "amd64")
if err != nil {
t.Fatal(err)
}
if string(got) != string(payload) {
t.Fatalf("binary mismatch: %q", got)
}
}
func TestApplyBinary(t *testing.T) {
dir := t.TempDir()
dest := filepath.Join(dir, "hp-viz")
payload := []byte{0x7f, 'E', 'L', 'F'}
if err := applyBinary(dest, payload); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if string(got) != string(payload) {
t.Fatal("written binary mismatch")
}
info, err := os.Stat(dest)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm()&0o111 == 0 {
t.Fatal("binary not executable")
}
}
func TestShouldSkipByInterval(t *testing.T) {
BuildCommit = "abc1234"
dir := t.TempDir()
statePath := filepath.Join(dir, "state.json")
_ = writeState(statePath, persistedState{
LastCheck: time.Now(),
LastCommit: "abc1234",
})
opts := DefaultOptions()
opts.StatePath = statePath
opts.Interval = time.Hour
if !shouldSkipByInterval(opts) {
t.Fatal("expected skip within interval")
}
}
func buildTestArchive(t *testing.T, name string, payload []byte) []byte {
t.Helper()
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
if err := tw.WriteHeader(&tar.Header{
Name: name,
Mode: 0o755,
Size: int64(len(payload)),
}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(payload); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func downloadBinaryWithPlatform(opts Options, goos, goarch string) ([]byte, error) {
pkg := fmt.Sprintf("hp-viz-%s-%s.tar.gz", goos, goarch)
binName := fmt.Sprintf("hp-viz-%s-%s", goos, goarch)
base := strings.TrimRight(opts.ReleaseBase, "/")
archiveURL := base + "/" + pkg
sumURL := archiveURL + ".sha256"
archive, err := downloadURL(opts.Client, archiveURL)
if err != nil {
return nil, err
}
sumBytes, err := downloadURL(opts.Client, sumURL)
if err != nil {
return nil, err
}
expected := strings.Fields(strings.TrimSpace(string(sumBytes)))[0]
if err := verifySHA256(archive, expected); err != nil {
return nil, err
}
return extractBinary(archive, binName)
}