Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <command> --help` for a specific one.
14 changes: 14 additions & 0 deletions cmd/anfra/detach_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//go:build !windows

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The file is configured with //go:build !windows, but there is no Windows implementation of detachProcess. This will cause compilation to fail on Windows with undefined: detachProcess.

To support Windows compilation (even if Windows builds are not officially released yet), please add a detach_windows.go file with a stub or Windows-specific implementation:

//go:build windows

package main

import "os/exec"

func detachProcess(cmd *exec.Cmd) {
	// Stub or Windows-specific process detaching logic
}


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}
}
11 changes: 10 additions & 1 deletion cmd/anfra/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
6 changes: 5 additions & 1 deletion cmd/anfra/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,11 @@ func serveMux(h hostContext, clients app.Clients) http.Handler {

// `help` (truthy) → the command's cobra help text, identical to `anfra <cmd> --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
Expand Down
149 changes: 149 additions & 0 deletions cmd/anfra/update.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +17 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pass the command's context cmd.Context() to runUpdate to support cancellation (e.g., if the user presses Ctrl+C during a large download).

Suggested change
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 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(cmd *cobra.Command, _ []string) error {
return runUpdate(cmd.Context(), 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()
Comment on lines +30 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update runUpdate to accept and use the passed context instead of context.Background().

Suggested change
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()
func runUpdate(ctx context.Context, 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).

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 <args...>` 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()
}
}
7 changes: 6 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
)
24 changes: 24 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
Loading
Loading