From eaa7344a24a0a2b5feaad1daba379a3611152557 Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 00:27:09 +0700 Subject: [PATCH 01/13] feat(script): uninstaller --- .gitignore | 1 - uninstall.sh | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100755 uninstall.sh diff --git a/.gitignore b/.gitignore index 5e15196..13f159c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,4 @@ iris iris.log autocomplete temp -uninstall.sh docs/guide.md \ No newline at end of file diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..8731a9b --- /dev/null +++ b/uninstall.sh @@ -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" From a53bd3cb3cb6ebb2c018ec6c8e642efd207aba81 Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 00:29:40 +0700 Subject: [PATCH 02/13] chore(scripts): move installer and uninstaller to scripts folder --- install.sh => scripts/install.sh | 2 +- uninstall.sh => scripts/uninstall.sh | 0 test.zsh | 1 - 3 files changed, 1 insertion(+), 2 deletions(-) rename install.sh => scripts/install.sh (98%) rename uninstall.sh => scripts/uninstall.sh (100%) delete mode 100644 test.zsh diff --git a/install.sh b/scripts/install.sh similarity index 98% rename from install.sh rename to scripts/install.sh index 9d75903..f57414b 100755 --- a/install.sh +++ b/scripts/install.sh @@ -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}" diff --git a/uninstall.sh b/scripts/uninstall.sh similarity index 100% rename from uninstall.sh rename to scripts/uninstall.sh diff --git a/test.zsh b/test.zsh deleted file mode 100644 index 2ee46a2..0000000 --- a/test.zsh +++ /dev/null @@ -1 +0,0 @@ -echo ${0:a:h} From e6f4bdd36d6024b2286985def87df30888244222 Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 00:52:34 +0700 Subject: [PATCH 03/13] feat(root): version --- root/version.go | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 root/version.go diff --git a/root/version.go b/root/version.go new file mode 100644 index 0000000..884717c --- /dev/null +++ b/root/version.go @@ -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" From 1e923a13d9816ce0414dbbd05d0fc219e40027e2 Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 00:53:06 +0700 Subject: [PATCH 04/13] feat(root): updater --- root/update.go | 255 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 root/update.go diff --git a/root/update.go b/root/update.go new file mode 100644 index 0000000..7045089 --- /dev/null +++ b/root/update.go @@ -0,0 +1,255 @@ +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.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() }() + + 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++ { + cv, _ := strconv.Atoi(cParts[i]) + lv, _ := strconv.Atoi(lParts[i]) + 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") + }, +} From e9f4b77276f1f4cba8e7ca42894ed536af78119b Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 00:53:39 +0700 Subject: [PATCH 05/13] chore(wrapper): add background update --- root/wrapper.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/root/wrapper.go b/root/wrapper.go index 2f5e267..eeaceb0 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -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 } From f9eb1900464cb0190a0120acacde2846b1726e4a Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 00:53:59 +0700 Subject: [PATCH 06/13] feat(test): update test --- tests/root/update_test.go | 63 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/root/update_test.go diff --git a/tests/root/update_test.go b/tests/root/update_test.go new file mode 100644 index 0000000..898f7e1 --- /dev/null +++ b/tests/root/update_test.go @@ -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) + } +} From 07556fdc760509a908a180143cc23015d0134442 Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 00:55:24 +0700 Subject: [PATCH 07/13] chore(justfile): update debug command --- go.mod | 2 +- justfile | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 0fe24ae..0de7f5d 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/justfile b/justfile index dec4950..2330e0e 100644 --- a/justfile +++ b/justfile @@ -47,4 +47,31 @@ analyze: # run linter [group('dev')] lint: - @golangci-lint run ./... \ No newline at end of file + @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 From 72dd6940e422932caa4977f61778f38917511ed6 Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 00:56:50 +0700 Subject: [PATCH 08/13] docs: updater --- docs/updater.md | 97 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/updater.md diff --git a/docs/updater.md b/docs/updater.md new file mode 100644 index 0000000..9dbfae6 --- /dev/null +++ b/docs/updater.md @@ -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` | From 88d6ee4e57d324076ae6b41aacc49241ad980ca5 Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 01:02:43 +0700 Subject: [PATCH 09/13] fix(history): fuzzy search shows commands that no one need --- integration/history.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/integration/history.go b/integration/history.go index d3d37c2..cfe6dd0 100644 --- a/integration/history.go +++ b/integration/history.go @@ -140,10 +140,24 @@ 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 != "" { + if fields := strings.Fields(m.Str); len(fields) > 0 { + if strings.ToLower(fields[0]) != queryFirstWord { + continue + } + } + } results = append(results, HistResult{ ID: idMapCache[m.Str], Cmd: m.Str, From caf4aa344e3f9ae1c0691cde3c91e89fa23d4629 Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 01:12:40 +0700 Subject: [PATCH 10/13] perf(history): finding the first space and using a case-insensitive comparison --- integration/history.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/integration/history.go b/integration/history.go index cfe6dd0..d350d77 100644 --- a/integration/history.go +++ b/integration/history.go @@ -152,10 +152,12 @@ func SearchHistory(query string) ([]HistResult, error) { var results []HistResult for _, m := range matches { if queryFirstWord != "" { - if fields := strings.Fields(m.Str); len(fields) > 0 { - if strings.ToLower(fields[0]) != queryFirstWord { - continue - } + 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{ From 21bd993469d3bebdef69eee790eb48a70d2b6ffd Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 01:13:28 +0700 Subject: [PATCH 11/13] chore(update): ensure the directory exists --- root/update.go | 1 + 1 file changed, 1 insertion(+) diff --git a/root/update.go b/root/update.go index 7045089..ae286e6 100644 --- a/root/update.go +++ b/root/update.go @@ -63,6 +63,7 @@ func SaveUpdateState(s updateState) { return } data, _ := json.MarshalIndent(s, "", " ") + _ = os.MkdirAll(filepath.Dir(file), 0755) _ = os.WriteFile(file, data, 0644) } From ffb97994020c0a6276240cdec36f935bc1462c8c Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 01:14:12 +0700 Subject: [PATCH 12/13] chore(update): check the HTTP response status code before reading the body --- root/update.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/root/update.go b/root/update.go index ae286e6..3ae242b 100644 --- a/root/update.go +++ b/root/update.go @@ -90,6 +90,10 @@ func FetchLatestVersion() (string, error) { } 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 From 5e7543b4d8379a292f8a8a5efcf8a87d979872ce Mon Sep 17 00:00:00 2001 From: verse91 Date: Sun, 10 May 2026 01:14:57 +0700 Subject: [PATCH 13/13] chore(update): also check for version string contains non-numeric characters --- root/update.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/root/update.go b/root/update.go index 3ae242b..a542f5a 100644 --- a/root/update.go +++ b/root/update.go @@ -131,8 +131,12 @@ func IsNewer(current, latest string) bool { // compare major.minor.patch for i := 0; i < len(cParts) && i < len(lParts); i++ { - cv, _ := strconv.Atoi(cParts[i]) - lv, _ := strconv.Atoi(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 }