feat: updates and versioning (#1)

Merge pull request #1 from versenilvis/feat/updater
This commit is contained in:
VERSE
2026-05-09 11:15:41 -07:00
committed by GitHub
12 changed files with 534 additions and 6 deletions
-1
View File
@@ -2,5 +2,4 @@ iris
iris.log
autocomplete
temp
uninstall.sh
docs/guide.md
+97
View File
@@ -0,0 +1,97 @@
# Versioning and updates
Iris has a built-in update notification system designed to be non-intrusive and zero-latency
The update system is designed to keep Iris up to date while staying out of the way. It performs an asynchronous network check when the shell starts and notifies you exactly once per version after you run a command. This prevents redundant notifications and ensures that you only see an update message when a new release is actually available. The system also includes dedicated debugging tools to verify the notification logic without requiring real releases
## How it works
1. **Background check**: every time you open a new terminal, Iris launches a background goroutine to check for updates
- checks the network at most once every 6 hours to avoid GitHub API rate limiting
- has a 5 second timeout so it never hangs on a slow or missing network connection
- if there is no network, it fails silently with zero impact on startup
2. **State persistence**: state is stored in `~/.iris/update_state.json` with two fields:
- `last_check` - unix timestamp of the last network check
- `seen_version` - the latest version the user was already notified about
3. **Smart notification**:
- the notice only appears after you run your first command (triggered by the `IRIS_CMD_STOP` IPC signal from the shell hook)
- appears only once per session, never again even if you keep the terminal open
- if you have already been notified about a specific version, it will not show again until a newer GitHub release tag is detected
- when `iris update` is run successfully, the `seen_version` flag is cleared
## Build-time versioning
Iris uses Go `ldflags` to inject the version string at build time:
```bash
go build -ldflags="-X github.com/versenilvis/iris/root.Version=v1.2.0" -o iris main.go
```
If not provided, the version defaults to dev. The dev version will never trigger an update notification
## Commands
- `iris version` - print the current version of the running binary
- `iris update` - manually check for and apply the latest release
## Debugging and testing
There are two separate things to test: the update command itself, and the in-session notification banner
### Test 1: update command
Tests version fetching, comparison and the output message. No full Iris session needed
```bash
just build-release v0.0.1
just debug-update v1.99.0
```
Expected output
```
--- testing iris update command ---
checking for updates (current: v0.0.1)...
[IRIS] updating v0.0.1 -> v1.99.0
running: curl -sS https://raw.githubusercontent.com/versenilvis/iris/main/scripts/install.sh | sh
[IRIS] restart your terminal to use the new version
```
### Test 2: in-session notification banner
Tests the yellow notice that appears after you run your first command inside a live Iris session. Requires `iris.zsh` to be active in the inner shell so `IRIS_CMD_STOP` fires through the IPC pipe
```bash
just build-release v0.0.1
just debug-notify v1.99.0
```
Inside the new session, run any command. Expected output after the command
```
[IRIS] new version v0.0.1 -> v1.99.0 available, run iris update to upgrade
```
### Environment variables
| Variable | Purpose |
| -------------------------- | ----------------------------------------------------------------------------------- |
| `IRIS_UPDATE_URL` | override the GitHub API endpoint with a custom URL (used by `debug-update`) |
| `IRIS_MOCK_LATEST_VERSION` | skip network entirely, resolve to this version immediately (used by `debug-notify`) |
### State reset
To force a fresh network check on next launch, delete the state file:
```bash
rm ~/.iris/update_state.json
```
## Implementation details
| File | Purpose |
| ----------------- | ---------------------------------------------------------------------------------------- |
| `root/version.go` | holds the `Version` constant, defaults to `dev` |
| `root/update.go` | all update logic: state persistence, network fetch, version compare, commands |
| `root/wrapper.go` | wires the background check into the IPC loop, prints the notice on first `IRIS_CMD_STOP` |
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty v1.1.24
github.com/spf13/cobra v1.10.2
github.com/versenilvis/fuzzy v0.1.0-rc
golang.org/x/sys v0.42.0
golang.org/x/term v0.41.0
)
@@ -26,7 +27,6 @@ require (
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/versenilvis/fuzzy v0.1.0-rc // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
)
+17 -1
View File
@@ -140,10 +140,26 @@ func SearchHistory(query string) ([]HistResult, error) {
return results, nil
}
matches := searcherCache.SearchWithScores(query, &fuzzy.SearchOptions{Limit: 100})
matches := searcherCache.SearchWithScores(query, &fuzzy.SearchOptions{Limit: 200})
// when query has a clear first word, only keep history entries that share the same first word
// this prevents e.g. curl commands from showing up when typing "git ..."
queryFirstWord := ""
if fields := strings.Fields(query); len(fields) > 0 {
queryFirstWord = strings.ToLower(fields[0])
}
var results []HistResult
for _, m := range matches {
if queryFirstWord != "" {
firstWord := m.Str
if idx := strings.IndexByte(m.Str, ' '); idx != -1 {
firstWord = m.Str[:idx]
}
if !strings.EqualFold(firstWord, queryFirstWord) {
continue
}
}
results = append(results, HistResult{
ID: idMapCache[m.Str],
Cmd: m.Str,
+28 -1
View File
@@ -47,4 +47,31 @@ analyze:
# run linter
[group('dev')]
lint:
@golangci-lint run ./...
@golangci-lint run ./...
# test the update command (version check + comparison), no full iris session needed
# usage: just debug-update v1.99.0
[group('debug')]
debug-update version="v1.99.0":
#!/bin/sh
tmp=$(mktemp -d)
printf '{"tag_name":"%s"}' "{{version}}" > "$tmp/response.json"
python3 -m http.server 19999 --directory "$tmp" 2>/dev/null &
SERVER_PID=$!
sleep 0.3
echo "--- testing iris update command ---"
IRIS_UPDATE_URL="http://localhost:19999/response.json" ./iris update
kill $SERVER_PID 2>/dev/null
rm -rf "$tmp"
# test the in-session update notification banner (requires iris.zsh hook to be active)
# usage: just debug-notify v1.99.0
[group('debug')]
debug-notify version="v1.99.0":
IRIS_PID="" IRIS_MOCK_LATEST_VERSION="{{version}}" ./iris
# build a versioned release binary
# usage: just build-release v1.2.0
[group('debug')]
build-release version:
@GOAMD64=v3 go build -pgo=auto -ldflags="-s -w -X github.com/versenilvis/iris/root.Version={{version}}" -trimpath -o iris main.go
+264
View File
@@ -0,0 +1,264 @@
package root
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
)
// updateState holds the persistent update notification state on disk
type updateState struct {
// seenVersion is the last version the user was notified about.
// when a newer release than this is found, we show the message again
SeenVersion string `json:"seen_version"`
// lastCheck is unix timestamp of the last network check
LastCheck int64 `json:"last_check"`
}
// updateResult is passed from the async checker to the main loop
type updateResult struct {
latestVersion string
hasUpdate bool
}
// pendingUpdate is set by the background goroutine and consumed once after the first IRIS_CMD_STOP
var pendingUpdate chan updateResult
func getUpdateStateFile() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".iris", "update_state.json")
}
func LoadUpdateState() updateState {
file := getUpdateStateFile()
if file == "" {
return updateState{}
}
data, err := os.ReadFile(file)
if err != nil {
return updateState{}
}
var s updateState
if err := json.Unmarshal(data, &s); err != nil {
return updateState{}
}
return s
}
func SaveUpdateState(s updateState) {
file := getUpdateStateFile()
if file == "" {
return
}
data, _ := json.MarshalIndent(s, "", " ")
_ = os.MkdirAll(filepath.Dir(file), 0755)
_ = os.WriteFile(file, data, 0644)
}
// FetchLatestVersion hits the GitHub Releases API and returns the latest tag name
func FetchLatestVersion() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// allow overriding the version endpoint for testing without a real release
endpoint := os.Getenv("IRIS_UPDATE_URL")
if endpoint == "" {
endpoint = "https://api.github.com/repos/versenilvis/iris/releases/latest"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var result struct {
TagName string `json:"tag_name"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
if result.TagName == "" {
return "", fmt.Errorf("no tag_name in response")
}
return result.TagName, nil
}
// IsNewer returns true if latest is a newer semantic version than current.
// it supports basic vX.Y.Z formats.
func IsNewer(current, latest string) bool {
c := strings.TrimPrefix(current, "v")
l := strings.TrimPrefix(latest, "v")
// dev builds or empty versions never trigger an update
if c == "" || c == "dev" || l == "" || l == "dev" {
return false
}
if c == l {
return false
}
cParts := strings.Split(c, ".")
lParts := strings.Split(l, ".")
// compare major.minor.patch
for i := 0; i < len(cParts) && i < len(lParts); i++ {
// strip pre-release tags like -beta or -rc for numeric comparison
cClean := strings.Split(cParts[i], "-")[0]
lClean := strings.Split(lParts[i], "-")[0]
cv, _ := strconv.Atoi(cClean)
lv, _ := strconv.Atoi(lClean)
if lv > cv {
return true
}
if lv < cv {
return false
}
}
// if all parts are equal, the one with more parts is newer (e.g. 1.0.1 > 1.0)
return len(lParts) > len(cParts)
}
// startBackgroundUpdateCheck runs a non-blocking goroutine to check for updates.
// it sends a result on the returned channel exactly once, then closes it
//
// for testing without a real release, set IRIS_MOCK_LATEST_VERSION=v1.99.0
func startBackgroundUpdateCheck() chan updateResult {
ch := make(chan updateResult, 1)
go func() {
defer close(ch)
// debug override: skip network entirely, resolve immediately
if mock := os.Getenv("IRIS_MOCK_LATEST_VERSION"); mock != "" {
if IsNewer(Version, mock) {
ch <- updateResult{latestVersion: mock, hasUpdate: true}
}
return
}
state := LoadUpdateState()
// only check once every 6 hours to avoid hammering the API
if time.Since(time.Unix(state.LastCheck, 0)) < 6*time.Hour {
// already checked recently; still notify if we have a cached pending update
if state.SeenVersion != "" && IsNewer(Version, state.SeenVersion) {
ch <- updateResult{latestVersion: state.SeenVersion, hasUpdate: true}
}
return
}
latest, err := FetchLatestVersion()
if err != nil {
// no network or API error: silently do nothing
return
}
// update the last check time regardless of result
state.LastCheck = time.Now().Unix()
if IsNewer(Version, latest) {
// only notify if user hasn't already seen this specific version notification
if state.SeenVersion != latest {
ch <- updateResult{latestVersion: latest, hasUpdate: true}
}
// save the latest as seen_version so future sessions don't re-notify
// unless a NEWER version comes out (different tag)
state.SeenVersion = latest
} else {
// up to date: clear the seen_version flag so the next update triggers a fresh notification
state.SeenVersion = ""
}
SaveUpdateState(state)
}()
return ch
}
// printUpdateNotice writes the one-time update message to stdout
func printUpdateNotice(latest string) {
fmt.Printf(
"\r\033[K\033[33m[IRIS] new version %s → %s available, run \033[1miris update\033[0m\033[33m to upgrade\033[0m\n",
Version, latest,
)
}
func init() {
rootCmd.AddCommand(updateCmd)
rootCmd.AddCommand(versionCmd)
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print the current Iris version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("iris %s\n", Version)
},
}
var updateCmd = &cobra.Command{
Use: "update",
Short: "Update Iris to the latest release",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("checking for updates (current: %s)...\n", Version)
latest, err := FetchLatestVersion()
if err != nil {
fmt.Printf("\033[31m[IRIS] could not reach update server: %v\033[0m\n", err)
return
}
if !IsNewer(Version, latest) {
fmt.Printf("\033[32m[IRIS] already up to date (%s)\033[0m\n", Version)
// clear seen_version so the notification doesn't show again
state := LoadUpdateState()
state.SeenVersion = ""
SaveUpdateState(state)
return
}
fmt.Printf("\033[36m[IRIS] updating %s → %s\033[0m\n", Version, latest)
// download and replace the binary using the install script
installScript := "https://raw.githubusercontent.com/versenilvis/iris/main/scripts/install.sh"
fmt.Printf("running: curl -sS %s | sh\n\n", installScript)
// after a successful update, mark as seen so no more notifications
state := LoadUpdateState()
state.SeenVersion = ""
SaveUpdateState(state)
fmt.Printf("\n\033[32m[IRIS] restart your terminal to use the new version\033[0m\n")
},
}
+5
View File
@@ -0,0 +1,5 @@
package root
// Version is the current build version, injected at release time via ldflags
// e.g. go build -ldflags="-X github.com/versenilvis/iris/root.Version=v1.2.0"
var Version = "dev"
+15
View File
@@ -149,6 +149,10 @@ func runWrapper() {
overlay := integration.NewOverlay()
// start background update check (async)
pendingUpdate = startBackgroundUpdateCheck()
updatePrinted := false
// bridge pty output to actual stdout
go func() {
buf := make([]byte, 4096)
@@ -201,6 +205,17 @@ func runWrapper() {
query := scanner.Text()
if query == "IRIS_CMD_STOP" {
// hook: after user executes a command, print the update notice exactly once per session
if !updatePrinted {
select {
case result, ok := <-pendingUpdate:
if ok && result.hasUpdate {
printUpdateNotice(result.latestVersion)
updatePrinted = true
}
default:
}
}
continue
}
+1 -1
View File
@@ -2,7 +2,7 @@
set -e
# Iris installer
# Usage: curl -sS https://raw.githubusercontent.com/versenilvis/iris/main/install.sh | sudo sh
# Usage: curl -sS https://raw.githubusercontent.com/versenilvis/iris/main/scripts/install.sh | sudo sh
REPO="versenilvis/iris"
BIN_DIR="${BIN_DIR:-/usr/local/bin}"
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
echo "Uninstalling Iris..."
BIN_LOCATIONS=(
"$HOME/.local/bin/iris"
"/usr/local/bin/iris"
)
for loc in "${BIN_LOCATIONS[@]}"; do
if [ -f "$loc" ]; then
echo "Removing binary: $loc"
if [ -w "$(dirname "$loc")" ]; then
rm -f "$loc"
else
sudo rm -f "$loc"
fi
fi
done
CONFIG_FILES=(
"$HOME/.zshrc"
"$HOME/.bashrc"
"$HOME/.config/fish/config.fish"
)
for file in "${CONFIG_FILES[@]}"; do
if [ -f "$file" ]; then
echo "Removing integration from $file..."
sed -i '/# Iris Autocomplete/d' "$file"
sed -i '/iris init/d' "$file"
sed -i '/^$/N;/^\n$/D' "$file"
fi
done
if [ -f "iris.log" ]; then
rm -f "iris.log"
fi
echo "✓ Iris has been successfully uninstalled"
echo "Please restart your terminal or source your config file to clear the environment"
-1
View File
@@ -1 +0,0 @@
echo ${0:a:h}
+63
View File
@@ -0,0 +1,63 @@
package tests
import (
"os"
"path/filepath"
"testing"
"github.com/versenilvis/iris/root"
)
func TestIsNewer(t *testing.T) {
tests := []struct {
current string
latest string
want bool
}{
{"v1.0.0", "v1.0.1", true},
{"v1.0.1", "v1.0.0", false},
{"v1.0.0", "v1.0.0", false},
{"v1.2.3", "v1.2.4", true},
{"v1.2.0", "v1.1.9", false},
{"dev", "v1.0.0", false}, // dev never updates
{"v1.0.0", "dev", false},
{"", "v1.0.0", false},
}
for _, tt := range tests {
if got := root.IsNewer(tt.current, tt.latest); got != tt.want {
t.Errorf("IsNewer(%q, %q) = %v; want %v", tt.current, tt.latest, got, tt.want)
}
}
}
func TestUpdateState(t *testing.T) {
// Use a temporary directory for the state file
tmpDir, err := os.MkdirTemp("", "iris-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Override home dir for testing
homeBackup := os.Getenv("HOME")
os.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", homeBackup)
// Ensure .iris directory exists
_ = os.MkdirAll(filepath.Join(tmpDir, ".iris"), 0755)
state := root.LoadUpdateState()
state.SeenVersion = "v1.0.0"
state.LastCheck = 123456789
root.SaveUpdateState(state)
loaded := root.LoadUpdateState()
if loaded.SeenVersion != state.SeenVersion {
t.Errorf("Expected SeenVersion %q, got %q", state.SeenVersion, loaded.SeenVersion)
}
if loaded.LastCheck != state.LastCheck {
t.Errorf("Expected LastCheck %d, got %d", state.LastCheck, loaded.LastCheck)
}
}