Initial commit on Forgejo
Some checks failed
CI and releases / Test and vet (push) Failing after 1s
CI and releases / Build release binaries (push) Has been skipped
CI and releases / Publish GitHub release (push) Has been skipped

Fresh repository history for elektrine/tarakan-client hosted at
https://git.elektrine.com/elektrine/tarakan-client.
This commit is contained in:
Maxfield Luke 2026-07-29 04:56:36 -04:00
commit 58b29a7f84
70 changed files with 12994 additions and 0 deletions

View file

@ -0,0 +1,263 @@
// Package updatecheck tells users when a newer tarakan release is available.
package updatecheck
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
// DefaultRepo is where official release binaries are published.
DefaultRepo = "atomine-elektrine/tarakan-client"
// InstallHint is the one-liner people already use to install or upgrade.
InstallHint = "curl -fsSL https://tarakan.lol/install.sh | bash"
cacheTTL = 24 * time.Hour
httpTimeout = 3 * time.Second
)
// githubAPIBase is overridden in tests.
var githubAPIBase = "https://api.github.com"
// Result is one comparison of the running binary against the latest release.
type Result struct {
Current string
Latest string
UpdateAvailable bool
}
// Notice is a short human line for stderr (empty when no update).
func (r Result) Notice() string {
if !r.UpdateAvailable || r.Latest == "" {
return ""
}
return fmt.Sprintf(
"tarakan %s is available (you have %s). Update:\n %s",
displayVersion(r.Latest),
displayVersion(r.Current),
InstallHint,
)
}
// MaybeNotify checks for a newer release and prints a notice to w when one
// exists. Network and disk errors are ignored so update checks never break
// normal CLI work. Set TARAKAN_SKIP_UPDATE_CHECK=1 to disable.
func MaybeNotify(w io.Writer, current string) {
if w == nil || skipEnabled() {
return
}
ctx, cancel := context.WithTimeout(context.Background(), httpTimeout)
defer cancel()
result, err := Check(ctx, current)
if err != nil || !result.UpdateAvailable {
return
}
if notice := result.Notice(); notice != "" {
fmt.Fprintln(w, notice)
}
}
// Check returns whether current is older than the latest GitHub release.
// Results are cached under the user config dir for cacheTTL.
func Check(ctx context.Context, current string) (Result, error) {
current = normalizeVersion(current)
result := Result{Current: current}
if current == "" || current == "dev" {
return result, nil
}
if skipEnabled() {
return result, nil
}
latest, fromCache, err := latestVersion(ctx)
if err != nil {
return result, err
}
result.Latest = latest
result.UpdateAvailable = compareVersions(current, latest) < 0
_ = fromCache
return result, nil
}
func skipEnabled() bool {
v := strings.TrimSpace(os.Getenv("TARAKAN_SKIP_UPDATE_CHECK"))
return v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
}
func latestVersion(ctx context.Context) (string, bool, error) {
if cached, ok := readCache(); ok {
return cached, true, nil
}
latest, err := fetchLatestGitHub(ctx, DefaultRepo)
if err != nil {
return "", false, err
}
_ = writeCache(latest)
return latest, false, nil
}
type cacheFile struct {
Latest string `json:"latest"`
CheckedAt time.Time `json:"checked_at"`
}
func cachePath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "tarakan", "update-check.json"), nil
}
func readCache() (string, bool) {
path, err := cachePath()
if err != nil {
return "", false
}
raw, err := os.ReadFile(path)
if err != nil {
return "", false
}
var c cacheFile
if err := json.Unmarshal(raw, &c); err != nil {
return "", false
}
if c.Latest == "" || time.Since(c.CheckedAt) > cacheTTL {
return "", false
}
return normalizeVersion(c.Latest), true
}
func writeCache(latest string) error {
path, err := cachePath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
raw, err := json.MarshalIndent(cacheFile{
Latest: normalizeVersion(latest),
CheckedAt: time.Now().UTC(),
}, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
return os.WriteFile(path, raw, 0o600)
}
func fetchLatestGitHub(ctx context.Context, repo string) (string, error) {
repo = strings.TrimSpace(repo)
if repo == "" {
return "", errors.New("empty repo")
}
url := strings.TrimRight(githubAPIBase, "/") + "/repos/" + repo + "/releases/latest"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "tarakan-client-updatecheck")
client := &http.Client{Timeout: httpTimeout}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("github releases: HTTP %d", resp.StatusCode)
}
var body struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
return "", err
}
tag := normalizeVersion(body.TagName)
if tag == "" {
return "", errors.New("github releases: empty tag_name")
}
return tag, nil
}
func displayVersion(v string) string {
v = normalizeVersion(v)
if v == "" {
return "unknown"
}
return "v" + v
}
// normalizeVersion strips a leading "v" and whitespace.
func normalizeVersion(v string) string {
v = strings.TrimSpace(v)
v = strings.TrimPrefix(v, "v")
v = strings.TrimPrefix(v, "V")
return strings.TrimSpace(v)
}
// compareVersions returns -1 if a < b, 0 if equal, 1 if a > b.
// Handles dotted numeric versions (1.2.3). Missing trailing segments are 0
// (so "0.2" equals "0.2.0"). Non-numeric tails compare as strings.
func compareVersions(a, b string) int {
a = normalizeVersion(a)
b = normalizeVersion(b)
if a == b {
return 0
}
as := strings.Split(a, ".")
bs := strings.Split(b, ".")
n := len(as)
if len(bs) > n {
n = len(bs)
}
for i := 0; i < n; i++ {
an, aOk := versionPart(as, i)
bn, bOk := versionPart(bs, i)
if aOk && bOk {
if an < bn {
return -1
}
if an > bn {
return 1
}
continue
}
// Fall back to string compare for pre-release-ish segments.
ap, bp := "", ""
if i < len(as) {
ap = as[i]
}
if i < len(bs) {
bp = bs[i]
}
if ap < bp {
return -1
}
if ap > bp {
return 1
}
}
return 0
}
func versionPart(parts []string, i int) (int, bool) {
if i >= len(parts) || parts[i] == "" {
return 0, true
}
n, err := strconv.Atoi(parts[i])
if err != nil {
return 0, false
}
return n, true
}

View file

@ -0,0 +1,131 @@
package updatecheck
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestCompareVersions(t *testing.T) {
tests := []struct {
a, b string
want int
}{
{"0.2.0", "0.2.0", 0},
{"v0.2.0", "0.2.0", 0},
{"0.2.0", "0.2.1", -1},
{"0.2.1", "0.2.0", 1},
{"0.1.9", "0.2.0", -1},
{"1.0.0", "0.9.9", 1},
{"0.2", "0.2.0", 0},
{"0.2.0", "0.2.0.1", -1},
}
for _, tc := range tests {
if got := compareVersions(tc.a, tc.b); got != tc.want {
t.Fatalf("compareVersions(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestResultNotice(t *testing.T) {
r := Result{Current: "0.2.0", Latest: "0.2.2", UpdateAvailable: true}
notice := r.Notice()
for _, want := range []string{"v0.2.2", "v0.2.0", InstallHint} {
if !strings.Contains(notice, want) {
t.Fatalf("notice missing %q: %s", want, notice)
}
}
if (Result{}).Notice() != "" {
t.Fatal("empty result should have no notice")
}
}
func TestCheckUsesCache(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
// UserConfigDir on some platforms ignores XDG; write via cachePath after override.
// Force cache into temp by monkeying through write/read with HOME/XDG.
// On Linux, UserConfigDir uses XDG_CONFIG_HOME when set.
path := filepath.Join(dir, "tarakan", "update-check.json")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(cacheFile{Latest: "0.9.0", CheckedAt: time.Now().UTC()})
if err := os.WriteFile(path, append(raw, '\n'), 0o600); err != nil {
t.Fatal(err)
}
// If UserConfigDir doesn't use our dir, skip (platform quirk).
if p, err := cachePath(); err != nil || p != path {
t.Skipf("cache path %q != %q (platform config dir)", p, path)
}
result, err := Check(context.Background(), "0.2.0")
if err != nil {
t.Fatal(err)
}
if !result.UpdateAvailable || result.Latest != "0.9.0" {
t.Fatalf("expected cached update, got %+v", result)
}
}
func TestCheckFetchesGitHubAndDetectsUpdate(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
if p, err := cachePath(); err != nil || !strings.HasPrefix(p, dir) {
t.Skip("UserConfigDir does not honor XDG_CONFIG_HOME on this platform")
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/atomine-elektrine/tarakan-client/releases/latest" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"tag_name": "v0.3.0"})
}))
defer server.Close()
prev := githubAPIBase
githubAPIBase = server.URL
t.Cleanup(func() { githubAPIBase = prev })
result, err := Check(context.Background(), "0.2.0")
if err != nil {
t.Fatal(err)
}
if !result.UpdateAvailable || result.Latest != "0.3.0" {
t.Fatalf("unexpected result: %+v", result)
}
// Second call should use cache (still works if server goes away).
server.Close()
result2, err := Check(context.Background(), "0.2.0")
if err != nil {
t.Fatal(err)
}
if result2.Latest != "0.3.0" {
t.Fatalf("cache miss: %+v", result2)
}
}
func TestMaybeNotifySkipsWhenDisabled(t *testing.T) {
t.Setenv("TARAKAN_SKIP_UPDATE_CHECK", "1")
var b strings.Builder
MaybeNotify(&b, "0.0.1")
if b.Len() != 0 {
t.Fatalf("expected silence when skip set, got %q", b.String())
}
}
func TestSkipEnabled(t *testing.T) {
t.Setenv("TARAKAN_SKIP_UPDATE_CHECK", "true")
if !skipEnabled() {
t.Fatal("expected skip")
}
}