diff --git a/CHANGELOG.md b/CHANGELOG.md index e67a5d0..f81dd6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## [](https://github.com/holistics/anfra/compare/anfra-v0.1.0...anfra-v) (2026-07-22) + +### Features + +* auto update mechanism ([180d9a2](https://github.com/holistics/anfra/commit/180d9a227ebbaaed1bebde90066587a97559cb13)) + +### Build + +* install.sh script ([a2c9ed6](https://github.com/holistics/anfra/commit/a2c9ed656a072f24ac08d41a45b205207a287125)) # Changelog All notable changes to anfra are documented here, generated from diff --git a/README.md b/README.md index ddbb2b0..1e45fa4 100644 --- a/README.md +++ b/README.md @@ -1 +1,31 @@ # anfra + +Local-first agentic analytics infrastructure — a single binary that runs an +AML/AQL engine and query layer against your data warehouse. + +## Install + +```sh +curl -fsSL https://raw.githubusercontent.com/holistics/anfra/main/install.sh | bash +``` + +The installer downloads the latest release for your platform, places the `anfra` +binary in `~/.anfra/bin`, and prints the line to add it to your `PATH`. + +Supported platforms: linux (x64/arm64) and macOS (x64/arm64). + +You can configure the installer with environment variables: + +- `ANFRA_INSTALL_DIR` — install somewhere else (default: `~/.anfra/bin`) +- `ANFRA_VERSION` — install a specific version, e.g. `0.1.0` (default: latest) + +## Updating + +```sh +anfra update # replace the binary with the latest release +anfra update --check # check for a newer release without installing +``` + +## Usage + +Run `anfra --help` for commands, or `anfra --help` for a specific one. diff --git a/cmd/anfra/detach_unix.go b/cmd/anfra/detach_unix.go new file mode 100644 index 0000000..f5373a9 --- /dev/null +++ b/cmd/anfra/detach_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package main + +import ( + "os/exec" + "syscall" +) + +// detachProcess puts the child in its own process group so it isn't killed when +// the foreground anfra process (and its group) exits. +func detachProcess(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} diff --git a/cmd/anfra/main.go b/cmd/anfra/main.go index ab89d9c..b9cafd9 100644 --- a/cmd/anfra/main.go +++ b/cmd/anfra/main.go @@ -16,7 +16,15 @@ type exitCodeError struct{ code int } func (e *exitCodeError) Error() string { return fmt.Sprintf("exit code %d", e.code) } func main() { - err := newRootCmd().Execute() + // ExecuteC returns the command that actually ran, so we get the real + // subcommand name (handles flags/args/aliases) rather than parsing os.Args. + executed, err := newRootCmd().ExecuteC() + // After the command runs, surface a cached "update available" notice (and, if + // opted in, kick a background update). Best-effort; never affects exit status. + if executed != nil { + maybeNotifyUpdate(executed.Name()) + } + if err == nil { return } @@ -40,6 +48,7 @@ func newRootCmd() *cobra.Command { SilenceErrors: true, } root.AddCommand(newServeCmd()) + root.AddCommand(newUpdateCmd(), newUpdateCheckCmd()) root.AddCommand(appCommands()...) // ping, query, … generated from the registry return root } diff --git a/cmd/anfra/serve.go b/cmd/anfra/serve.go index cbe50ac..a724276 100644 --- a/cmd/anfra/serve.go +++ b/cmd/anfra/serve.go @@ -111,7 +111,11 @@ func serveMux(h hostContext, clients app.Clients) http.Handler { // `help` (truthy) → the command's cobra help text, identical to `anfra --help`. if app.IsTruthy(req.Args["help"]) { - text, err := commandHelp(req.Command) + // commandHelp only renders help text; it builds the command tree but never + // executes a RunE, so there is no request context to thread into the update + // command's HTTP client (which is why contextcheck is suppressed here). + text, err := commandHelp(req.Command) //nolint:contextcheck + if err != nil { writeCallError(w, http.StatusNotFound, err.Error()) return diff --git a/cmd/anfra/update.go b/cmd/anfra/update.go new file mode 100644 index 0000000..0d0ca32 --- /dev/null +++ b/cmd/anfra/update.go @@ -0,0 +1,149 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + + "github.com/holistics/anfra/internal/meta" + "github.com/holistics/anfra/internal/update" + "github.com/spf13/cobra" +) + +// newUpdateCmd is the manual self-update command (like `gh`/`deno upgrade`): +// check the latest GitHub release and, unless --check, replace this binary. +func newUpdateCmd() *cobra.Command { + var checkOnly bool + cmd := &cobra.Command{ + Use: "update", + Short: "Update anfra to the latest release (use --check to only report)", + RunE: func(_ *cobra.Command, _ []string) error { + return runUpdate(checkOnly) + }, + } + cmd.Flags().BoolVar(&checkOnly, "check", false, "only check for an update; do not install") + return cmd +} + +func runUpdate(checkOnly bool) error { + // Root at Background (repo convention, see host.go/runServe); timeouts are + // bounded per-request inside the update package (a quick lookup, then a large + // download). + ctx := context.Background() + rel, err := update.Latest(ctx) + if err != nil { + return err + } + // Record the check so the background notice stays quiet right after. + update.RecordCheck(rel) + + if !rel.IsNewer() { + fmt.Printf("anfra is up to date (%s).\n", meta.Version) + return nil + } + if checkOnly { + fmt.Printf("Update available: %s (you have %s). Run `anfra update` to install.\n", rel.Tag, meta.Version) + return nil + } + + fmt.Printf("Downloading %s (~250 MB)...\n", rel.Tag) + // Show a progress line only on an interactive terminal; stay silent when + // output is piped/captured (agent, CI). + var progress io.Writer + if stderrIsInteractive() { + progress = os.Stderr + } + if err := update.Apply(ctx, rel, progress); err != nil { + return err + } + fmt.Printf("Updated anfra %s -> %s.\n", meta.Version, rel.Tag) + return nil +} + +// newUpdateCheckCmd is a hidden command run detached in the background to +// refresh the cached update check without blocking the foreground command. +func newUpdateCheckCmd() *cobra.Command { + return &cobra.Command{ + Use: "__update-check", + Hidden: true, + RunE: func(_ *cobra.Command, _ []string) error { + return update.Refresh(context.Background()) + }, + } +} + +// commands for which the background update notice is suppressed (they either +// do their own checking or are long-running/internal). +var noNotifyCommands = map[string]bool{"update": true, "__update-check": true, "serve": true} + +// updateNotifyDisabled reports whether the background update notice is opted out. +func updateNotifyDisabled() bool { + return os.Getenv("ANFRA_NO_UPDATE_NOTIFIER") != "" +} + +// autoUpdateEnabled reports whether opt-in fully-automatic update is on. When set, +// a known-newer version is applied in a detached background process (effective on +// the next run) instead of only printing a notice. +func autoUpdateEnabled() bool { + v := os.Getenv("ANFRA_AUTO_UPDATE") + return v != "" && v != "0" && v != "false" +} + +// maybeNotifyUpdate prints a cached "update available" notice (to stderr, so it +// never pollutes command output). If opt-in auto-update is on, it instead applies +// the update in a detached background process. When the cache is stale it spawns a +// detached refresh so the next run's notice is current. Best-effort and silent on +// any error — an update check must never break a command. +func maybeNotifyUpdate(invoked string) { + if updateNotifyDisabled() || noNotifyCommands[invoked] { + return + } + notice := update.CachedNotice() + + // Opt-in auto-update is explicit, so it runs regardless of interactivity + // (e.g. a service that set ANFRA_AUTO_UPDATE=1). + if notice != "" && autoUpdateEnabled() { + fmt.Fprintln(os.Stderr, "\n"+notice+" (auto-updating in the background)") + spawnDetached("update") + return + } + + // The passive notice and its background refresh are for interactive humans + // only. When output is piped/captured — an agent calling anfra, CI, a script — + // stay completely silent and spawn nothing, so we add no noise or overhead. + if !stderrIsInteractive() { + return + } + if notice != "" { + fmt.Fprintln(os.Stderr, "\n"+notice) + } + if update.Stale() { + spawnDetached("__update-check") + } +} + +// stderrIsInteractive reports whether stderr is a terminal (not a pipe/file). +func stderrIsInteractive() bool { + fi, err := os.Stderr.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + +// spawnDetached launches `anfra ` detached from this process so it +// survives after the foreground command exits (the update-notifier pattern). +func spawnDetached(args ...string) { + exe, err := os.Executable() + if err != nil { + return + } + cmd := exec.Command(exe, args...) //nolint:gosec // fixed args, our own binary + cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil + detachProcess(cmd) + if err := cmd.Start(); err == nil { + _ = cmd.Process.Release() + } +} diff --git a/go.mod b/go.mod index fbe5962..cfb56e8 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,18 @@ module github.com/holistics/anfra -go 1.25 +go 1.25.0 require ( + github.com/minio/selfupdate v0.6.0 github.com/spf13/cobra v1.10.2 + golang.org/x/mod v0.38.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + aead.dev/minisign v0.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b // indirect + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect ) diff --git a/go.sum b/go.sum index 47edb24..0fbdf02 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,36 @@ +aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk= +aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= +github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4= +golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e5657d3 --- /dev/null +++ b/install.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# +# anfra installer. +# +# curl -fsSL https://raw.githubusercontent.com/holistics/anfra/main/install.sh | bash +# +# Downloads the anfra release binary for this platform from GitHub Releases and +# installs it to ~/.anfra/bin (override with ANFRA_INSTALL_DIR). The release +# binary embeds both sidecars, so it's large (~250 MB). +# +# Environment: +# ANFRA_INSTALL_DIR install location (default: $HOME/.anfra/bin) +# ANFRA_VERSION pin a version, e.g. 0.1.0 (default: latest) +# ANFRA_NO_MODIFY_PATH if set, don't touch shell rc files; just print the hint +# +# Downloads from public GitHub Releases (no auth). Trust model: downloads over +# HTTPS from GitHub Releases (TOFU). Signature +# verification is not done here yet — `anfra update` is where verification will +# live (see .agents/projects/anfra/signing.md). + +set -euo pipefail + +REPO="holistics/anfra" +BIN_NAME="anfra" +INSTALL_DIR="${ANFRA_INSTALL_DIR:-${HOME}/.anfra/bin}" + +err() { echo "anfra-install: $*" >&2; exit 1; } + +# --- detect platform, mapped to the release asset names (anfra--) --- +os="$(uname -s | tr '[:upper:]' '[:lower:]')" +case "$os" in + linux) os="linux" ;; + darwin) os="darwin" ;; + *) err "unsupported OS: $os (anfra supports linux and macOS)" ;; +esac + +arch="$(uname -m)" +case "$arch" in + x86_64|amd64) arch="x64" ;; + arm64|aarch64) arch="arm64" ;; + *) err "unsupported architecture: $arch" ;; +esac + +asset="${BIN_NAME}-${os}-${arch}" + +# --- resolve the download URL (avoid the GitHub API + its 60 req/hr limit) --- +# The /releases/latest/download/ and /releases/download// +# endpoints 302 straight to the CDN, so no API call is needed. +if [ -n "${ANFRA_VERSION:-}" ]; then + tag="${ANFRA_VERSION#anfra-v}"; tag="anfra-v${tag#v}" + url="https://github.com/${REPO}/releases/download/${tag}/${asset}" +else + url="https://github.com/${REPO}/releases/latest/download/${asset}" +fi + +# --- download --- +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +echo "Downloading ${asset} (~250 MB) for ${os}/${arch}..." +if ! curl -fL -o "${tmp}/${BIN_NAME}" "$url"; then + err "download failed from ${url} (is the release published for this platform?)" +fi + +# Log the checksum for the record (TOFU; no verification yet). +if command -v sha256sum >/dev/null 2>&1; then + echo "SHA256: $(sha256sum "${tmp}/${BIN_NAME}" | awk '{print $1}')" +elif command -v shasum >/dev/null 2>&1; then + echo "SHA256: $(shasum -a 256 "${tmp}/${BIN_NAME}" | awk '{print $1}')" +fi + +chmod +x "${tmp}/${BIN_NAME}" +# macOS: the binary is unsigned, so clear the Gatekeeper quarantine flag. +if [ "$os" = "darwin" ] && command -v xattr >/dev/null 2>&1; then + xattr -d com.apple.quarantine "${tmp}/${BIN_NAME}" 2>/dev/null || true +fi + +# --- install --- +mkdir -p "$INSTALL_DIR" +mv -f "${tmp}/${BIN_NAME}" "${INSTALL_DIR}/${BIN_NAME}" +target="${INSTALL_DIR}/${BIN_NAME}" +[ -x "$target" ] || err "installation failed: $target is not executable" + +version="$("$target" --version 2>/dev/null || echo "installed")" +echo "Installed ${version} to ${target}" + +# --- ensure INSTALL_DIR is on PATH --- +path_hint() { + echo + echo "Add ${INSTALL_DIR} to your PATH:" + echo " export PATH=\"${INSTALL_DIR}:\$PATH\"" +} + +ensure_on_path() { + # Already reachable — nothing to do. + case ":${PATH}:" in *":${INSTALL_DIR}:"*) return 0 ;; esac + + if [ -n "${ANFRA_NO_MODIFY_PATH:-}" ]; then + path_hint + return 0 + fi + + current="$(basename "${SHELL:-}")" + updated="" + # "::" — edit an rc file when it already exists, + # or when it belongs to the user's current shell (created if missing). The + # "bash-login" rows cover macOS, where login shells read .bash_profile/.profile + # instead of .bashrc; they're only edited if present (never created, so we + # don't shadow an existing .profile). + for entry in \ + "bash:${HOME}/.bashrc:export PATH=\"${INSTALL_DIR}:\$PATH\"" \ + "bash-login:${HOME}/.bash_profile:export PATH=\"${INSTALL_DIR}:\$PATH\"" \ + "bash-login:${HOME}/.profile:export PATH=\"${INSTALL_DIR}:\$PATH\"" \ + "zsh:${HOME}/.zshrc:export PATH=\"${INSTALL_DIR}:\$PATH\"" \ + "fish:${HOME}/.config/fish/config.fish:fish_add_path \"${INSTALL_DIR}\"" + do + shell="${entry%%:*}"; rest="${entry#*:}"; rc="${rest%%:*}"; line="${rest#*:}" + if [ -f "$rc" ] || [ "$shell" = "$current" ]; then + mkdir -p "$(dirname "$rc")" + if ! { [ -f "$rc" ] && grep -qF "$line" "$rc"; }; then + printf '\n# Added by anfra installer\n%s\n' "$line" >> "$rc" + echo "Added ${INSTALL_DIR} to PATH in ${rc}" + fi + updated="yes" + fi + done + + if [ -n "$updated" ]; then + echo "Restart your shell (or 'source' the file above) to use \`anfra\`." + else + path_hint + fi +} + +ensure_on_path diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..15b0125 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,338 @@ +// Package update implements anfra's self-update: discover the latest GitHub +// release, compare it to the compiled-in version, download the binary for this +// platform, and atomically replace the running executable. +// +// Follows the common CLI pattern (gh, deno, bun): a manual `anfra update` command +// plus a cached, non-blocking "update available" notice. Fully-automatic update is +// opt-in (ANFRA_AUTO_UPDATE), never the default. +package update + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/holistics/anfra/internal/meta" + "github.com/minio/selfupdate" + "golang.org/x/mod/semver" +) + +const ( + // The GitHub repo whose Releases hold the anfra binaries. + repoSlug = "holistics/anfra" + tagPrefix = "anfra-v" + apiBase = "https://api.github.com" + userAgent = "anfra-cli" + + // How long a cached update check stays fresh (matches gh/deno's 24h). + checkTTL = 24 * time.Hour + + // Per-request timeouts: the release lookup is a small JSON response; the asset + // download is a large binary (sidecars are embedded), so it gets much longer. + lookupTimeout = 30 * time.Second + downloadTimeout = 10 * time.Minute +) + +// Release is the latest release plus the asset for the running platform. +type Release struct { + Version string // e.g. "0.2.0" (tag without the anfra-v prefix) + Tag string // e.g. "anfra-v0.2.0" + AssetURL string // GitHub API asset URL (octet-stream download) for this platform +} + +// assetName maps the running platform to its release asset name (anfra-), +// matching build_release.yml's per-target output. Windows is not built yet. +func assetName() (string, error) { + switch runtime.GOOS + "/" + runtime.GOARCH { + case "linux/amd64": + return "anfra-linux-x64", nil + case "linux/arm64": + return "anfra-linux-arm64", nil + case "darwin/amd64": + return "anfra-darwin-x64", nil + case "darwin/arm64": + return "anfra-darwin-arm64", nil + default: + return "", fmt.Errorf("no anfra release build for %s/%s", runtime.GOOS, runtime.GOARCH) + } +} + +// authToken returns a GitHub token from the environment, or "" for anonymous. +// Used only as a fallback when an anonymous request is rejected (the repo/releases +// are private until public distribution). +func authToken() string { + for _, k := range []string{"ANFRA_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"} { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + return v + } + } + return "" +} + +// get does an anonymous GET; if it's rejected (401/403/404) and a token is +// available, it retries authenticated. accept selects the response format; +// timeout bounds the whole exchange (including reading the body). +func get(ctx context.Context, url, accept string, timeout time.Duration) (*http.Response, error) { + client := &http.Client{Timeout: timeout} + do := func(token string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", accept) + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", userAgent) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + return client.Do(req) + } + + resp, err := do("") + if err != nil { + return nil, err + } + // Anonymous rejected + a token on hand → retry authenticated (private repo). + if resp.StatusCode == 401 || resp.StatusCode == 403 || resp.StatusCode == 404 { + if token := authToken(); token != "" { + resp.Body.Close() + return do(token) + } + } + return resp, nil +} + +// Latest fetches the newest release and resolves the asset for this platform. +func Latest(ctx context.Context) (*Release, error) { + want, err := assetName() + if err != nil { + return nil, err + } + resp, err := get(ctx, fmt.Sprintf("%s/repos/%s/releases/latest", apiBase, repoSlug), "application/vnd.github+json", lookupTimeout) + if err != nil { + return nil, fmt.Errorf("check latest release: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("check latest release: GitHub returned %s (set ANFRA_GITHUB_TOKEN if the repo is private)", resp.Status) + } + + var rel struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + URL string `json:"url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("decode release: %w", err) + } + out := &Release{Tag: rel.TagName, Version: strings.TrimPrefix(rel.TagName, tagPrefix)} + for _, a := range rel.Assets { + if a.Name == want { + out.AssetURL = a.URL + break + } + } + if out.AssetURL == "" { + return nil, fmt.Errorf("release %s has no asset %q", rel.TagName, want) + } + return out, nil +} + +// IsNewer reports whether the latest version is newer than the running one. A +// local "dev" build is always considered outdated so the notice/update fires. +func (r *Release) IsNewer() bool { + cur := meta.Version + if cur == "dev" || cur == "" { + return true + } + // Normalize to canonical vX.Y.Z so a stray "v"/"anfra-v" prefix on either side + // can't produce an invalid string (e.g. "vv0.2.0"), which Compare treats as 0. + return semver.Compare(canonicalVersion(r.Version), canonicalVersion(cur)) > 0 +} + +// canonicalVersion strips any anfra-v / v prefix and re-adds a single "v", so +// semver.Compare always sees a valid version regardless of the input format. +func canonicalVersion(v string) string { + v = strings.TrimPrefix(v, tagPrefix) + v = strings.TrimPrefix(v, "v") + return "v" + v +} + +// Apply downloads the platform asset and atomically replaces the running binary. +// If progress is non-nil, download progress is rendered to it (a single, in-place +// updated line); pass nil for a silent download (agents/CI). +func Apply(ctx context.Context, r *Release, progress io.Writer) error { + resp, err := get(ctx, r.AssetURL, "application/octet-stream", downloadTimeout) + if err != nil { + return fmt.Errorf("download %s: %w", r.Tag, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download %s: GitHub returned %s", r.Tag, resp.Status) + } + var body io.Reader = resp.Body + if progress != nil { + body = &progressReader{r: resp.Body, total: resp.ContentLength, w: progress} + } + if err := selfupdate.Apply(body, selfupdate.Options{}); err != nil { + if rerr := selfupdate.RollbackError(err); rerr != nil { + return fmt.Errorf("update failed and rollback also failed: %v (rollback: %v)", err, rerr) + } + return fmt.Errorf("apply update: %w", err) + } + return nil +} + +// progressReader wraps the download stream and renders a single, carriage-return +// updated progress line as bytes flow through. It redraws only when the whole +// percent changes (or every ~1 MB when the total is unknown), so it stays cheap. +type progressReader struct { + r io.Reader + w io.Writer + total int64 // -1 if the server didn't send Content-Length + read int64 + lastPct int + lastN int64 + done bool +} + +func (p *progressReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + if n > 0 { + p.read += int64(n) + p.maybeRender() + } + if err == io.EOF && !p.done { + p.done = true + p.render() + fmt.Fprintln(p.w) + } + return n, err +} + +func (p *progressReader) maybeRender() { + if p.total > 0 { + if pct := int(p.read * 100 / p.total); pct != p.lastPct { + p.lastPct = pct + p.render() + } + return + } + if p.read-p.lastN >= 1<<20 { + p.lastN = p.read + p.render() + } +} + +func (p *progressReader) render() { + if p.total > 0 { + fmt.Fprintf(p.w, "\r downloading %3d%% (%s / %s) ", p.read*100/p.total, humanBytes(p.read), humanBytes(p.total)) + return + } + fmt.Fprintf(p.w, "\r downloading %s ", humanBytes(p.read)) +} + +// humanBytes renders a byte count as a human-readable size (e.g. "117.2 MB"). +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for x := n / unit; x >= unit; x /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp]) +} + +// --- cached background check (for the "update available" notice) --- + +type cache struct { + CheckedAt time.Time `json:"checked_at"` + LatestVersion string `json:"latest_version"` + LatestTag string `json:"latest_tag"` +} + +func cachePath() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "anfra", "update-check.json"), nil +} + +func readCache() (*cache, bool) { + p, err := cachePath() + if err != nil { + return nil, false + } + data, err := os.ReadFile(p) //nolint:gosec // G304: our own cache path, not user input + if err != nil { + return nil, false + } + var c cache + if json.Unmarshal(data, &c) != nil { + return nil, false + } + return &c, true +} + +func writeCache(c *cache) { + p, err := cachePath() + if err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { //nolint:gosec // G301: cache dir + return + } + if data, err := json.Marshal(c); err == nil { + _ = os.WriteFile(p, data, 0o644) //nolint:gosec // G306: non-secret cache file + } +} + +// Stale reports whether the cached check is missing or older than the TTL. +func Stale() bool { + c, ok := readCache() + return !ok || time.Since(c.CheckedAt) > checkTTL +} + +// Refresh runs a live check and updates the cache. Called from the detached +// background refresh (see cmd/anfra), never in a command's hot path. +func Refresh(ctx context.Context) error { + rel, err := Latest(ctx) + if err != nil { + return err + } + RecordCheck(rel) + return nil +} + +// RecordCheck stores an already-fetched release in the cache (no network), so a +// foreground check refreshes the notice cache without a second request. +func RecordCheck(r *Release) { + writeCache(&cache{CheckedAt: time.Now(), LatestVersion: r.Version, LatestTag: r.Tag}) +} + +// CachedNotice returns a one-line "update available" message from the cached +// check, or "" if none is available / the cache says we're current. +func CachedNotice() string { + c, ok := readCache() + if !ok || c.LatestVersion == "" { + return "" + } + r := &Release{Version: c.LatestVersion, Tag: c.LatestTag} + if !r.IsNewer() { + return "" + } + return fmt.Sprintf("anfra %s is available (you have %s). Run `anfra update`.", c.LatestTag, meta.Version) +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..df13b52 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,89 @@ +package update + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/holistics/anfra/internal/meta" +) + +func TestIsNewer(t *testing.T) { + orig := meta.Version + t.Cleanup(func() { meta.Version = orig }) + + cases := []struct { + current string + latest string + want bool + }{ + {"0.1.0", "0.2.0", true}, + {"0.1.0", "0.1.1", true}, + {"0.2.0", "0.1.0", false}, + {"0.1.0", "0.1.0", false}, + {"dev", "0.1.0", true}, // local build is always outdated + {"", "0.1.0", true}, // unset behaves like dev + {"1.0.0", "0.9.9", false}, + // Mixed prefixes must normalize, not degrade to a 0/invalid compare. + {"v0.1.0", "0.2.0", true}, + {"0.1.0", "v0.2.0", true}, + {"v1.0.0", "v1.0.0", false}, + {"anfra-v0.1.0", "0.2.0", true}, + } + for _, c := range cases { + meta.Version = c.current + got := (&Release{Version: c.latest}).IsNewer() + if got != c.want { + t.Errorf("current=%q latest=%q: IsNewer()=%v, want %v", c.current, c.latest, got, c.want) + } + } +} + +func TestProgressReader(t *testing.T) { + // Drain 1000 bytes with a known total through the progress reader. + var buf bytes.Buffer + pr := &progressReader{r: bytes.NewReader(make([]byte, 1000)), w: &buf, total: 1000} + n, err := io.Copy(io.Discard, pr) + if err != nil { + t.Fatalf("copy: %v", err) + } + if n != 1000 { + t.Fatalf("copied %d bytes, want 1000", n) + } + out := buf.String() + if !strings.Contains(out, "downloading") { + t.Errorf("no progress rendered: %q", out) + } + if !strings.Contains(out, "100%") { + t.Errorf("final progress not 100%%: %q", out) + } + if !strings.HasSuffix(out, "\n") { + t.Errorf("progress did not end with a newline: %q", out) + } +} + +func TestHumanBytes(t *testing.T) { + cases := map[int64]string{ + 512: "512 B", + 1024: "1.0 KB", + 272 * 1024 * 1024: "272.0 MB", + } + for n, want := range cases { + if got := humanBytes(n); got != want { + t.Errorf("humanBytes(%d)=%q, want %q", n, got, want) + } + } +} + +func TestAssetName(t *testing.T) { + // The mapping must stay in lockstep with build_release.yml's per-target names. + name, err := assetName() + if err != nil { + // Only fails on a platform anfra isn't built for; skip rather than fail. + t.Skipf("no build for this platform: %v", err) + } + if name == "" { + t.Fatal("assetName returned empty name with no error") + } +} diff --git a/manifest.yml b/manifest.yml index 7b874dd..c12a797 100644 --- a/manifest.yml +++ b/manifest.yml @@ -4,7 +4,7 @@ # which triggers build_release.yml to download the pinned sidecars, cross-build, # and publish. Always bump `version` when changing a pin — otherwise the tag # already exists and no new release is cut. -version: 0.1.0 +version: 0.2.0 sidecars: anfra_node: anfra-node-v0.0.3 # holistics/holistics-core release tag (anfra_node_build_binaries.yml) canal_query: query-v2.12.0 # holistics/canal release tag (build_and_release_binaries.yml)