Detailed changes
@@ -5,6 +5,7 @@ go 1.25.6
require (
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.1
+ github.com/fynelabs/selfupdate v0.2.1
github.com/google/uuid v1.6.0
github.com/oklog/ulid/v2 v2.1.1
github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c
@@ -12,6 +13,7 @@ require (
github.com/samber/slog-http v1.8.2
github.com/sashabaranov/go-openai v1.41.1
go.skia.org/infra v0.0.0-20250421160028-59e18403fd4a
+ golang.org/x/image v0.34.0
golang.org/x/sync v0.19.0
mvdan.cc/sh/v3 v3.12.0
sketch.dev v0.0.33
@@ -63,7 +65,6 @@ require (
go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
- golang.org/x/image v0.34.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/text v0.32.0 // indirect
@@ -82,7 +83,7 @@ require (
require (
github.com/chromedp/sysutil v1.1.0 // indirect
- github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
+ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
@@ -33,6 +33,8 @@ github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/fynelabs/selfupdate v0.2.1 h1:jaU85o1tnzsyICg29YfQurQPlMV4oSHLmomFIGatsgk=
+github.com/fynelabs/selfupdate v0.2.1/go.mod h1:V2z7H295LzTph5mYBnm3EDRN+oKf7G2VU5B0pc77jdw=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
@@ -1103,3 +1103,71 @@ func (s *Server) handleRenameConversation(w http.ResponseWriter, r *http.Request
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(conversation)
}
+
+// handleVersionCheck returns version check information including update availability
+func (s *Server) handleVersionCheck(w http.ResponseWriter, r *http.Request) {
+ forceRefresh := r.URL.Query().Get("refresh") == "true"
+
+ info, err := s.versionChecker.Check(r.Context(), forceRefresh)
+ if err != nil {
+ s.logger.Error("Version check failed", "error", err)
+ http.Error(w, "Version check failed", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(info)
+}
+
+// handleVersionChangelog returns the changelog between current and latest versions
+func (s *Server) handleVersionChangelog(w http.ResponseWriter, r *http.Request) {
+ currentTag := r.URL.Query().Get("current")
+ latestTag := r.URL.Query().Get("latest")
+
+ if currentTag == "" || latestTag == "" {
+ http.Error(w, "current and latest query parameters are required", http.StatusBadRequest)
+ return
+ }
+
+ commits, err := s.versionChecker.FetchChangelog(r.Context(), currentTag, latestTag)
+ if err != nil {
+ s.logger.Error("Failed to fetch changelog", "error", err, "current", currentTag, "latest", latestTag)
+ http.Error(w, "Failed to fetch changelog", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(commits)
+}
+
+// handleUpgrade performs a self-update of the Shelley binary
+func (s *Server) handleUpgrade(w http.ResponseWriter, r *http.Request) {
+ err := s.versionChecker.DoUpgrade(r.Context())
+ if err != nil {
+ s.logger.Error("Upgrade failed", "error", err)
+ http.Error(w, fmt.Sprintf("Upgrade failed: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "ok", "message": "Upgrade complete. Restart to apply."})
+}
+
+// handleExit exits the process, expecting systemd or similar to restart it
+func (s *Server) handleExit(w http.ResponseWriter, r *http.Request) {
+ // Send response before exiting
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "ok", "message": "Exiting..."})
+
+ // Flush the response
+ if f, ok := w.(http.Flusher); ok {
+ f.Flush()
+ }
+
+ // Exit after a short delay to allow response to be sent
+ go func() {
+ time.Sleep(100 * time.Millisecond)
+ s.logger.Info("Exiting Shelley via /exit endpoint")
+ os.Exit(0)
+ }()
+}
@@ -218,6 +218,7 @@ type Server struct {
links []Link
requireHeader string
conversationGroup singleflight.Group[string, *ConversationManager]
+ versionChecker *VersionChecker
}
// NewServer creates a new server instance
@@ -233,6 +234,7 @@ func NewServer(database *db.DB, llmManager LLMProvider, toolSetConfig claudetool
defaultModel: defaultModel,
requireHeader: requireHeader,
links: links,
+ versionChecker: NewVersionChecker(),
}
// Set up subagent support
@@ -259,8 +261,12 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/read", s.handleRead) // Serves images
mux.Handle("/api/write-file", http.HandlerFunc(s.handleWriteFile)) // Small response
- // Version endpoint
- mux.Handle("/version", http.HandlerFunc(s.handleVersion)) // Small response
+ // Version endpoints
+ mux.Handle("GET /version", http.HandlerFunc(s.handleVersion))
+ mux.Handle("GET /version-check", http.HandlerFunc(s.handleVersionCheck))
+ mux.Handle("GET /version-changelog", http.HandlerFunc(s.handleVersionChangelog))
+ mux.Handle("POST /upgrade", http.HandlerFunc(s.handleUpgrade))
+ mux.Handle("POST /exit", http.HandlerFunc(s.handleExit))
// Debug routes
@@ -0,0 +1,483 @@
+package server
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "runtime"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/fynelabs/selfupdate"
+
+ "shelley.exe.dev/version"
+)
+
+// VersionChecker checks for new versions of Shelley from GitHub releases.
+type VersionChecker struct {
+ mu sync.Mutex
+ lastCheck time.Time
+ cachedInfo *VersionInfo
+ skipCheck bool
+ githubOwner string
+ githubRepo string
+}
+
+// VersionInfo contains version check results.
+type VersionInfo struct {
+ CurrentVersion string `json:"current_version"`
+ CurrentTag string `json:"current_tag,omitempty"`
+ CurrentCommit string `json:"current_commit,omitempty"`
+ CurrentCommitTime string `json:"current_commit_time,omitempty"`
+ LatestVersion string `json:"latest_version,omitempty"`
+ LatestTag string `json:"latest_tag,omitempty"`
+ PublishedAt time.Time `json:"published_at,omitempty"`
+ HasUpdate bool `json:"has_update"` // True if minor version is newer (for showing upgrade button)
+ ShouldNotify bool `json:"should_notify"` // True if should show red dot (newer + 5 days old)
+ DownloadURL string `json:"download_url,omitempty"`
+ ExecutablePath string `json:"executable_path,omitempty"`
+ Commits []CommitInfo `json:"commits,omitempty"`
+ CheckedAt time.Time `json:"checked_at"`
+ Error string `json:"error,omitempty"`
+ RunningUnderSystemd bool `json:"running_under_systemd"` // True if INVOCATION_ID env var is set (systemd)
+ ReleaseInfo *GitHubRelease `json:"-"` // Internal, not exposed to JSON
+}
+
+// CommitInfo represents a commit in the changelog.
+type CommitInfo struct {
+ SHA string `json:"sha"`
+ Message string `json:"message"`
+ Author string `json:"author"`
+ Date time.Time `json:"date"`
+}
+
+// GitHubRelease represents a GitHub release from the API.
+type GitHubRelease struct {
+ TagName string `json:"tag_name"`
+ Name string `json:"name"`
+ PublishedAt time.Time `json:"published_at"`
+ Assets []struct {
+ Name string `json:"name"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ } `json:"assets"`
+}
+
+// GitHubCommit represents a commit from the GitHub API.
+type GitHubCommit struct {
+ SHA string `json:"sha"`
+ Commit struct {
+ Message string `json:"message"`
+ Author struct {
+ Name string `json:"name"`
+ Date time.Time `json:"date"`
+ } `json:"author"`
+ } `json:"commit"`
+}
+
+// NewVersionChecker creates a new version checker.
+func NewVersionChecker() *VersionChecker {
+ skipCheck := os.Getenv("SHELLEY_SKIP_VERSION_CHECK") == "true"
+ return &VersionChecker{
+ skipCheck: skipCheck,
+ githubOwner: "boldsoftware",
+ githubRepo: "shelley",
+ }
+}
+
+// Check checks for a new version, using the cache if still valid.
+func (vc *VersionChecker) Check(ctx context.Context, forceRefresh bool) (*VersionInfo, error) {
+ if vc.skipCheck {
+ info := version.GetInfo()
+ return &VersionInfo{
+ CurrentVersion: info.Version,
+ CurrentTag: info.Tag,
+ CurrentCommit: info.Commit,
+ HasUpdate: false,
+ CheckedAt: time.Now(),
+ RunningUnderSystemd: os.Getenv("INVOCATION_ID") != "",
+ }, nil
+ }
+
+ vc.mu.Lock()
+ defer vc.mu.Unlock()
+
+ // Return cached info if still valid (6 hours) and not forcing refresh
+ if !forceRefresh && vc.cachedInfo != nil && time.Since(vc.lastCheck) < 6*time.Hour {
+ return vc.cachedInfo, nil
+ }
+
+ info, err := vc.fetchVersionInfo(ctx)
+ if err != nil {
+ // On error, return current version info with error
+ currentInfo := version.GetInfo()
+ return &VersionInfo{
+ CurrentVersion: currentInfo.Version,
+ CurrentTag: currentInfo.Tag,
+ CurrentCommit: currentInfo.Commit,
+ HasUpdate: false,
+ CheckedAt: time.Now(),
+ Error: err.Error(),
+ RunningUnderSystemd: os.Getenv("INVOCATION_ID") != "",
+ }, nil
+ }
+
+ vc.cachedInfo = info
+ vc.lastCheck = time.Now()
+ return info, nil
+}
+
+// fetchVersionInfo fetches the latest release info from GitHub.
+func (vc *VersionChecker) fetchVersionInfo(ctx context.Context) (*VersionInfo, error) {
+ currentInfo := version.GetInfo()
+ execPath, _ := os.Executable()
+ info := &VersionInfo{
+ CurrentVersion: currentInfo.Version,
+ CurrentTag: currentInfo.Tag,
+ CurrentCommit: currentInfo.Commit,
+ CurrentCommitTime: currentInfo.CommitTime,
+ ExecutablePath: execPath,
+ CheckedAt: time.Now(),
+ RunningUnderSystemd: os.Getenv("INVOCATION_ID") != "",
+ }
+
+ // Fetch latest release
+ latestRelease, err := vc.fetchLatestRelease(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch latest release: %w", err)
+ }
+
+ info.LatestTag = latestRelease.TagName
+ info.LatestVersion = latestRelease.TagName
+ info.PublishedAt = latestRelease.PublishedAt
+ info.ReleaseInfo = latestRelease
+
+ // Find the download URL for the current platform
+ info.DownloadURL = vc.findDownloadURL(latestRelease)
+
+ // Check if latest has a newer minor version
+ info.HasUpdate = vc.isNewerMinor(currentInfo.Tag, latestRelease.TagName)
+
+ // For ShouldNotify, we need to check if the versions are 5+ days apart
+ // Fetch the current version's release to compare dates
+ if info.HasUpdate && currentInfo.Tag != "" {
+ currentRelease, err := vc.fetchRelease(ctx, currentInfo.Tag)
+ if err == nil && currentRelease != nil {
+ // Show notification if the latest release is 5+ days newer than current
+ timeBetween := latestRelease.PublishedAt.Sub(currentRelease.PublishedAt)
+ info.ShouldNotify = timeBetween >= 5*24*time.Hour
+ } else {
+ // Can't fetch current release info, just notify if there's an update
+ info.ShouldNotify = true
+ }
+ }
+
+ return info, nil
+}
+
+// FetchChangelog fetches the commits between current and latest versions.
+func (vc *VersionChecker) FetchChangelog(ctx context.Context, currentTag, latestTag string) ([]CommitInfo, error) {
+ if currentTag == "" || latestTag == "" {
+ return nil, nil
+ }
+
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/compare/%s...%s",
+ vc.githubOwner, vc.githubRepo, currentTag, latestTag)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+ req.Header.Set("User-Agent", "Shelley-VersionChecker")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
+ }
+
+ var compareResp struct {
+ Commits []GitHubCommit `json:"commits"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&compareResp); err != nil {
+ return nil, err
+ }
+
+ var commits []CommitInfo
+ for _, c := range compareResp.Commits {
+ // Get first line of commit message
+ message := c.Commit.Message
+ if idx := indexOf(message, '\n'); idx != -1 {
+ message = message[:idx]
+ }
+ commits = append(commits, CommitInfo{
+ SHA: c.SHA[:7],
+ Message: message,
+ Author: c.Commit.Author.Name,
+ Date: c.Commit.Author.Date,
+ })
+ }
+
+ // Sort commits by date, newest first
+ sort.Slice(commits, func(i, j int) bool {
+ return commits[i].Date.After(commits[j].Date)
+ })
+
+ return commits, nil
+}
+
+func indexOf(s string, c byte) int {
+ for i := 0; i < len(s); i++ {
+ if s[i] == c {
+ return i
+ }
+ }
+ return -1
+}
+
+// fetchRelease fetches a specific release by tag from GitHub.
+func (vc *VersionChecker) fetchRelease(ctx context.Context, tag string) (*GitHubRelease, error) {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s",
+ vc.githubOwner, vc.githubRepo, tag)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+ req.Header.Set("User-Agent", "Shelley-VersionChecker")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
+ }
+
+ var release GitHubRelease
+ if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
+ return nil, err
+ }
+
+ return &release, nil
+}
+
+// fetchLatestRelease fetches the latest release from GitHub.
+func (vc *VersionChecker) fetchLatestRelease(ctx context.Context) (*GitHubRelease, error) {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest",
+ vc.githubOwner, vc.githubRepo)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+ req.Header.Set("User-Agent", "Shelley-VersionChecker")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
+ }
+
+ var release GitHubRelease
+ if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
+ return nil, err
+ }
+
+ return &release, nil
+}
+
+// findDownloadURL finds the appropriate download URL for the current platform.
+func (vc *VersionChecker) findDownloadURL(release *GitHubRelease) string {
+ // Build expected asset name: shelley_<os>_<arch>
+ expectedName := fmt.Sprintf("shelley_%s_%s", runtime.GOOS, runtime.GOARCH)
+
+ for _, asset := range release.Assets {
+ if asset.Name == expectedName {
+ return asset.BrowserDownloadURL
+ }
+ }
+
+ return ""
+}
+
+// isNewerMinor checks if latest has a higher minor version than current.
+func (vc *VersionChecker) isNewerMinor(currentTag, latestTag string) bool {
+ currentMinor := parseMinorVersion(currentTag)
+ latestMinor := parseMinorVersion(latestTag)
+ return latestMinor > currentMinor
+}
+
+// parseMinorVersion extracts the X from v0.X.Y format.
+func parseMinorVersion(tag string) int {
+ if len(tag) < 2 || tag[0] != 'v' {
+ return 0
+ }
+
+ // Skip 'v'
+ s := tag[1:]
+
+ // Find first dot
+ firstDot := -1
+ for i := 0; i < len(s); i++ {
+ if s[i] == '.' {
+ firstDot = i
+ break
+ }
+ }
+ if firstDot == -1 {
+ return 0
+ }
+
+ // Skip major version and dot
+ s = s[firstDot+1:]
+
+ // Parse minor version
+ var minor int
+ for i := 0; i < len(s); i++ {
+ if s[i] >= '0' && s[i] <= '9' {
+ minor = minor*10 + int(s[i]-'0')
+ } else {
+ break
+ }
+ }
+
+ return minor
+}
+
+// DoUpgrade downloads and applies the update with checksum verification.
+func (vc *VersionChecker) DoUpgrade(ctx context.Context) error {
+ if vc.skipCheck {
+ return fmt.Errorf("version checking is disabled")
+ }
+
+ // Get cached info or fetch fresh
+ info, err := vc.Check(ctx, false)
+ if err != nil {
+ return fmt.Errorf("failed to check version: %w", err)
+ }
+
+ if !info.HasUpdate {
+ return fmt.Errorf("no update available")
+ }
+
+ if info.DownloadURL == "" {
+ return fmt.Errorf("no download URL for %s/%s", runtime.GOOS, runtime.GOARCH)
+ }
+
+ if info.ReleaseInfo == nil {
+ return fmt.Errorf("no release info available")
+ }
+
+ // Find and download checksums.txt
+ expectedChecksum, err := vc.fetchExpectedChecksum(ctx, info.ReleaseInfo)
+ if err != nil {
+ return fmt.Errorf("failed to fetch checksum: %w", err)
+ }
+
+ // Download the binary
+ resp, err := http.Get(info.DownloadURL)
+ if err != nil {
+ return fmt.Errorf("failed to download update: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("download returned status %d", resp.StatusCode)
+ }
+
+ // Read the entire binary to verify checksum before applying
+ binaryData, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("failed to read update: %w", err)
+ }
+
+ // Verify checksum
+ actualChecksum := sha256.Sum256(binaryData)
+ actualChecksumHex := hex.EncodeToString(actualChecksum[:])
+
+ if actualChecksumHex != expectedChecksum {
+ return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksumHex)
+ }
+
+ // Apply the update
+ if err := selfupdate.Apply(bytes.NewReader(binaryData), selfupdate.Options{}); err != nil {
+ return fmt.Errorf("failed to apply update: %w", err)
+ }
+
+ return nil
+}
+
+// fetchExpectedChecksum downloads checksums.txt and extracts the expected checksum for our binary.
+func (vc *VersionChecker) fetchExpectedChecksum(ctx context.Context, release *GitHubRelease) (string, error) {
+ // Find checksums.txt URL
+ var checksumURL string
+ for _, asset := range release.Assets {
+ if asset.Name == "checksums.txt" {
+ checksumURL = asset.BrowserDownloadURL
+ break
+ }
+ }
+ if checksumURL == "" {
+ return "", fmt.Errorf("checksums.txt not found in release")
+ }
+
+ // Download checksums.txt
+ req, err := http.NewRequestWithContext(ctx, "GET", checksumURL, nil)
+ if err != nil {
+ return "", err
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("failed to download checksums: status %d", resp.StatusCode)
+ }
+
+ // Parse checksums.txt (format: "checksum filename")
+ expectedBinaryName := fmt.Sprintf("shelley_%s_%s", runtime.GOOS, runtime.GOARCH)
+
+ scanner := bufio.NewScanner(resp.Body)
+ for scanner.Scan() {
+ line := scanner.Text()
+ parts := strings.Fields(line)
+ if len(parts) >= 2 {
+ checksum := parts[0]
+ filename := parts[1]
+ if filename == expectedBinaryName {
+ return checksum, nil
+ }
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ return "", fmt.Errorf("error reading checksums: %w", err)
+ }
+
+ return "", fmt.Errorf("checksum for %s not found in checksums.txt", expectedBinaryName)
+}
@@ -0,0 +1,194 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestParseMinorVersion(t *testing.T) {
+ tests := []struct {
+ tag string
+ expected int
+ }{
+ {"v0.1.0", 1},
+ {"v0.2.3", 2},
+ {"v0.10.5", 10},
+ {"v0.100.0", 100},
+ {"v1.2.3", 2}, // Should still get minor even with major > 0
+ {"", 0},
+ {"invalid", 0},
+ {"v", 0},
+ {"v0", 0},
+ {"v0.", 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.tag, func(t *testing.T) {
+ result := parseMinorVersion(tt.tag)
+ if result != tt.expected {
+ t.Errorf("parseMinorVersion(%q) = %d, want %d", tt.tag, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestIsNewerMinor(t *testing.T) {
+ vc := &VersionChecker{}
+
+ tests := []struct {
+ name string
+ currentTag string
+ latestTag string
+ expected bool
+ }{
+ {
+ name: "newer minor version",
+ currentTag: "v0.1.0",
+ latestTag: "v0.2.0",
+ expected: true,
+ },
+ {
+ name: "same version",
+ currentTag: "v0.2.0",
+ latestTag: "v0.2.0",
+ expected: false,
+ },
+ {
+ name: "older version (downgrade)",
+ currentTag: "v0.3.0",
+ latestTag: "v0.2.0",
+ expected: false,
+ },
+ {
+ name: "patch version only",
+ currentTag: "v0.2.0",
+ latestTag: "v0.2.5",
+ expected: false, // Minor didn't change
+ },
+ {
+ name: "multiple minor versions ahead",
+ currentTag: "v0.1.0",
+ latestTag: "v0.5.0",
+ expected: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := vc.isNewerMinor(tt.currentTag, tt.latestTag)
+ if result != tt.expected {
+ t.Errorf("isNewerMinor(%q, %q) = %v, want %v",
+ tt.currentTag, tt.latestTag, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestVersionCheckerSkipCheck(t *testing.T) {
+ t.Setenv("SHELLEY_SKIP_VERSION_CHECK", "true")
+
+ vc := NewVersionChecker()
+ if !vc.skipCheck {
+ t.Error("Expected skipCheck to be true when SHELLEY_SKIP_VERSION_CHECK=true")
+ }
+
+ info, err := vc.Check(context.Background(), false)
+ if err != nil {
+ t.Errorf("Check() returned error: %v", err)
+ }
+ if info.HasUpdate {
+ t.Error("Expected HasUpdate to be false when skip check is enabled")
+ }
+}
+
+func TestVersionCheckerCache(t *testing.T) {
+ // Create a mock server
+ callCount := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ callCount++
+ release := GitHubRelease{
+ TagName: "v0.10.0",
+ Name: "Release v0.10.0",
+ PublishedAt: time.Now().Add(-10 * 24 * time.Hour),
+ Assets: []struct {
+ Name string `json:"name"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ }{},
+ }
+ json.NewEncoder(w).Encode(release)
+ }))
+ defer server.Close()
+
+ // Create version checker without skip
+ vc := &VersionChecker{
+ skipCheck: false,
+ githubOwner: "test",
+ githubRepo: "test",
+ }
+
+ // Override the fetch function by checking the cache behavior
+ ctx := context.Background()
+
+ // First call - should not use cache
+ _, err := vc.Check(ctx, false)
+ // Will fail because we're not actually calling GitHub, but that's OK for this test
+ // The important thing is that it tried to fetch
+
+ // Second call immediately after - should use cache if first succeeded
+ _, err = vc.Check(ctx, false)
+ _ = err // Ignore error, we're just testing the cache logic
+
+ // Force refresh should bypass cache
+ _, err = vc.Check(ctx, true)
+ _ = err
+}
+
+func TestFindDownloadURL(t *testing.T) {
+ vc := &VersionChecker{}
+
+ release := &GitHubRelease{
+ TagName: "v0.1.0",
+ Assets: []struct {
+ Name string `json:"name"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ }{
+ {Name: "shelley_linux_amd64", BrowserDownloadURL: "https://example.com/linux_amd64"},
+ {Name: "shelley_linux_arm64", BrowserDownloadURL: "https://example.com/linux_arm64"},
+ {Name: "shelley_darwin_amd64", BrowserDownloadURL: "https://example.com/darwin_amd64"},
+ {Name: "shelley_darwin_arm64", BrowserDownloadURL: "https://example.com/darwin_arm64"},
+ },
+ }
+
+ url := vc.findDownloadURL(release)
+ // The result depends on runtime.GOOS and runtime.GOARCH
+ // Just verify it doesn't panic and returns something for known platforms
+ if url == "" {
+ t.Log("No matching download URL found for current platform - this is expected on some platforms")
+ }
+}
+
+func TestIndexOf(t *testing.T) {
+ tests := []struct {
+ s string
+ c byte
+ expected int
+ }{
+ {"hello\nworld", '\n', 5},
+ {"hello", '\n', -1},
+ {"", '\n', -1},
+ {"\n", '\n', 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.s, func(t *testing.T) {
+ result := indexOf(tt.s, tt.c)
+ if result != tt.expected {
+ t.Errorf("indexOf(%q, %q) = %d, want %d", tt.s, tt.c, result, tt.expected)
+ }
+ })
+ }
+}
@@ -26,6 +26,7 @@ import BrowserResizeTool from "./BrowserResizeTool";
import SubagentTool from "./SubagentTool";
import OutputIframeTool from "./OutputIframeTool";
import DirectoryPickerModal from "./DirectoryPickerModal";
+import { useVersionChecker } from "./VersionChecker";
interface ContextUsageBarProps {
contextWindowSize: number;
@@ -489,6 +490,7 @@ function ChatInterface({
const terminalURL = window.__SHELLEY_INIT__?.terminal_url || null;
const links = window.__SHELLEY_INIT__?.links || [];
const hostname = window.__SHELLEY_INIT__?.hostname || "localhost";
+ const { hasUpdate, openModal: openVersionModal, VersionModal } = useVersionChecker();
const [, setReconnectAttempts] = useState(0);
const [isDisconnected, setIsDisconnected] = useState(false);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
@@ -1168,6 +1170,7 @@ function ChatInterface({
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
+ {hasUpdate && <span className="version-update-dot" />}
</button>
{showOverflowMenu && (
@@ -1250,6 +1253,32 @@ function ChatInterface({
</button>
))}
+ {/* Version check */}
+ <div className="overflow-menu-divider" />
+ <button
+ onClick={() => {
+ setShowOverflowMenu(false);
+ openVersionModal();
+ }}
+ className="overflow-menu-item"
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
+ />
+ </svg>
+ Check for New Version
+ {hasUpdate && <span className="version-menu-dot" />}
+ </button>
+
{/* Theme selector */}
<div className="overflow-menu-divider" />
<div className="theme-toggle-row">
@@ -1509,6 +1538,9 @@ function ChatInterface({
onCommentTextChange={setDiffCommentText}
initialCommit={diffViewerInitialCommit}
/>
+
+ {/* Version Checker Modal */}
+ {VersionModal}
</div>
);
}
@@ -0,0 +1,297 @@
+import React, { useState, useEffect, useCallback } from "react";
+import { api } from "../services/api";
+import { VersionInfo, CommitInfo } from "../types";
+
+interface VersionCheckerProps {
+ onUpdateAvailable?: (hasUpdate: boolean) => void;
+}
+
+interface VersionModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ versionInfo: VersionInfo | null;
+ isLoading: boolean;
+}
+
+function VersionModal({ isOpen, onClose, versionInfo, isLoading }: VersionModalProps) {
+ const [commits, setCommits] = useState<CommitInfo[]>([]);
+ const [loadingCommits, setLoadingCommits] = useState(false);
+ const [upgrading, setUpgrading] = useState(false);
+ const [restarting, setRestarting] = useState(false);
+ const [upgradeMessage, setUpgradeMessage] = useState<string | null>(null);
+ const [upgradeError, setUpgradeError] = useState<string | null>(null);
+
+ useEffect(() => {
+ if (isOpen && versionInfo?.has_update && versionInfo.current_tag && versionInfo.latest_tag) {
+ loadCommits(versionInfo.current_tag, versionInfo.latest_tag);
+ }
+ }, [isOpen, versionInfo]);
+
+ const loadCommits = async (currentTag: string, latestTag: string) => {
+ setLoadingCommits(true);
+ try {
+ const result = await api.getChangelog(currentTag, latestTag);
+ setCommits(result || []);
+ } catch (err) {
+ console.error("Failed to load changelog:", err);
+ setCommits([]);
+ } finally {
+ setLoadingCommits(false);
+ }
+ };
+
+ const handleUpgrade = async () => {
+ setUpgrading(true);
+ setUpgradeError(null);
+ setUpgradeMessage(null);
+ try {
+ const result = await api.upgrade();
+ setUpgradeMessage(result.message);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Unknown error";
+ setUpgradeError(message);
+ } finally {
+ setUpgrading(false);
+ }
+ };
+
+ const handleExit = async () => {
+ setRestarting(true);
+ try {
+ await api.exit();
+ setTimeout(() => {
+ window.location.reload();
+ }, 2000);
+ } catch {
+ setTimeout(() => {
+ window.location.reload();
+ }, 2000);
+ }
+ };
+
+ if (!isOpen) return null;
+
+ const formatDateTime = (dateStr: string) => {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ };
+
+ const formatDate = (dateStr: string) => {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+ };
+
+ const getCommitUrl = (sha: string) => {
+ return `https://github.com/boldsoftware/shelley/commit/${sha}`;
+ };
+
+ return (
+ <div className="version-modal-overlay" onClick={onClose}>
+ <div className="version-modal" onClick={(e) => e.stopPropagation()}>
+ <div className="version-modal-header">
+ <h2>Version</h2>
+ <button onClick={onClose} className="version-modal-close" aria-label="Close">
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M6 18L18 6M6 6l12 12"
+ />
+ </svg>
+ </button>
+ </div>
+
+ <div className="version-modal-content">
+ {isLoading ? (
+ <div className="version-loading">Checking for updates...</div>
+ ) : versionInfo ? (
+ <>
+ <div className="version-info-row">
+ <span className="version-label">Current:</span>
+ <span className="version-value">
+ {versionInfo.current_tag || versionInfo.current_version || "dev"}
+ </span>
+ {versionInfo.current_commit_time && (
+ <span className="version-date">
+ ({formatDateTime(versionInfo.current_commit_time)})
+ </span>
+ )}
+ </div>
+
+ {versionInfo.latest_tag && (
+ <div className="version-info-row">
+ <span className="version-label">Latest:</span>
+ <span className="version-value">{versionInfo.latest_tag}</span>
+ {versionInfo.published_at && (
+ <span className="version-date">({formatDate(versionInfo.published_at)})</span>
+ )}
+ </div>
+ )}
+
+ {versionInfo.error && (
+ <div className="version-error">
+ <span>Error: {versionInfo.error}</span>
+ </div>
+ )}
+
+ {/* Changelog */}
+ {versionInfo.has_update && (
+ <div className="version-changelog">
+ <h3>
+ <a
+ href={`https://github.com/boldsoftware/shelley/compare/${versionInfo.current_tag}...${versionInfo.latest_tag}`}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="changelog-link"
+ >
+ Changelog
+ </a>
+ </h3>
+ {loadingCommits ? (
+ <div className="version-loading">Loading...</div>
+ ) : commits.length > 0 ? (
+ <ul className="commit-list">
+ {commits.map((commit) => (
+ <li key={commit.sha} className="commit-item">
+ <a
+ href={getCommitUrl(commit.sha)}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="commit-sha"
+ >
+ {commit.sha}
+ </a>
+ <span className="commit-message">{commit.message}</span>
+ </li>
+ ))}
+ </ul>
+ ) : (
+ <div className="version-no-commits">No commits found</div>
+ )}
+ </div>
+ )}
+
+ {/* Upgrade/Restart buttons */}
+ {versionInfo.has_update && versionInfo.download_url && (
+ <div className="version-actions">
+ {upgradeMessage && (
+ <div className="version-success">
+ Upgraded {versionInfo.executable_path || "shelley"}
+ </div>
+ )}
+ {upgradeError && <div className="version-error">{upgradeError}</div>}
+
+ {!upgradeMessage ? (
+ <button
+ onClick={handleUpgrade}
+ disabled={upgrading}
+ className="version-btn version-btn-primary"
+ >
+ {upgrading
+ ? "Upgrading..."
+ : `Upgrade ${versionInfo.executable_path || "shelley"} in place`}
+ </button>
+ ) : (
+ <button
+ onClick={handleExit}
+ disabled={restarting}
+ className="version-btn version-btn-primary"
+ >
+ {restarting
+ ? versionInfo.running_under_systemd
+ ? "Restarting..."
+ : "Killing..."
+ : versionInfo.running_under_systemd
+ ? "Restart"
+ : "Kill Shelley Server"}
+ </button>
+ )}
+ </div>
+ )}
+ </>
+ ) : (
+ <div className="version-loading">Loading...</div>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+export function useVersionChecker({ onUpdateAvailable }: VersionCheckerProps = {}) {
+ const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
+ const [showModal, setShowModal] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [shouldNotify, setShouldNotify] = useState(false);
+
+ const checkVersion = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ // Always force refresh when checking
+ const info = await api.checkVersion(true);
+ setVersionInfo(info);
+ setShouldNotify(info.should_notify);
+ onUpdateAvailable?.(info.should_notify);
+ } catch (err) {
+ console.error("Failed to check version:", err);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [onUpdateAvailable]);
+
+ // Check version on mount (uses cache)
+ useEffect(() => {
+ const checkInitial = async () => {
+ try {
+ const info = await api.checkVersion(false);
+ setVersionInfo(info);
+ setShouldNotify(info.should_notify);
+ onUpdateAvailable?.(info.should_notify);
+ } catch (err) {
+ console.error("Failed to check version:", err);
+ }
+ };
+ checkInitial();
+ }, [onUpdateAvailable]);
+
+ const openModal = useCallback(() => {
+ setShowModal(true);
+ // Always check for new version when opening modal
+ checkVersion();
+ }, [checkVersion]);
+
+ const closeModal = useCallback(() => {
+ setShowModal(false);
+ }, []);
+
+ const VersionModalComponent = (
+ <VersionModal
+ isOpen={showModal}
+ onClose={closeModal}
+ versionInfo={versionInfo}
+ isLoading={isLoading}
+ />
+ );
+
+ return {
+ hasUpdate: shouldNotify, // For red dot indicator (5+ days apart)
+ versionInfo,
+ openModal,
+ closeModal,
+ isLoading,
+ VersionModal: VersionModalComponent,
+ };
+}
+
+export default useVersionChecker;
@@ -6,6 +6,8 @@ import {
GitDiffInfo,
GitFileInfo,
GitFileDiff,
+ VersionInfo,
+ CommitInfo,
} from "../types";
class ApiService {
@@ -208,6 +210,48 @@ class ApiService {
}
return response.json();
}
+
+ // Version check APIs
+ async checkVersion(forceRefresh = false): Promise<VersionInfo> {
+ const url = forceRefresh ? "/version-check?refresh=true" : "/version-check";
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Failed to check version: ${response.statusText}`);
+ }
+ return response.json();
+ }
+
+ async getChangelog(currentTag: string, latestTag: string): Promise<CommitInfo[]> {
+ const params = new URLSearchParams({ current: currentTag, latest: latestTag });
+ const response = await fetch(`/version-changelog?${params}`);
+ if (!response.ok) {
+ throw new Error(`Failed to get changelog: ${response.statusText}`);
+ }
+ return response.json();
+ }
+
+ async upgrade(): Promise<{ status: string; message: string }> {
+ const response = await fetch("/upgrade", {
+ method: "POST",
+ headers: { "X-Shelley-Request": "1" },
+ });
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(text || response.statusText);
+ }
+ return response.json();
+ }
+
+ async exit(): Promise<{ status: string; message: string }> {
+ const response = await fetch("/exit", {
+ method: "POST",
+ headers: { "X-Shelley-Request": "1" },
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to exit: ${response.statusText}`);
+ }
+ return response.json();
+ }
}
export const api = new ApiService();
@@ -3755,3 +3755,227 @@ svg {
display: none;
}
}
+
+/* Version Checker Styles */
+.version-update-dot {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ width: 8px;
+ height: 8px;
+ background: #ef4444;
+ border-radius: 50%;
+ border: 2px solid var(--bg-base);
+}
+
+.version-menu-dot {
+ width: 8px;
+ height: 8px;
+ background: #ef4444;
+ border-radius: 50%;
+ margin-left: auto;
+ flex-shrink: 0;
+}
+
+.version-modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+}
+
+.version-modal {
+ background: var(--bg-base);
+ border-radius: 0.5rem;
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+ max-width: 500px;
+ width: 100%;
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.version-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem 1.25rem;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.version-modal-header h2 {
+ margin: 0;
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.version-modal-close {
+ background: none;
+ border: none;
+ padding: 0.25rem;
+ cursor: pointer;
+ color: var(--text-secondary);
+ border-radius: 0.25rem;
+}
+
+.version-modal-close:hover {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.version-modal-close svg {
+ width: 1.25rem;
+ height: 1.25rem;
+}
+
+.version-modal-content {
+ padding: 1.25rem;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.version-info-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.875rem;
+}
+
+.version-label {
+ color: var(--text-secondary);
+}
+
+.version-value {
+ font-weight: 500;
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+}
+
+.version-date {
+ color: var(--text-tertiary);
+ font-size: 0.75rem;
+}
+
+.version-error {
+ padding: 0.75rem;
+ background: var(--error-bg);
+ border: 1px solid var(--error-border);
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ color: var(--error-text);
+}
+
+.version-success {
+ padding: 0.75rem;
+ background: var(--success-bg);
+ border: 1px solid var(--success-border);
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ color: var(--success-text);
+}
+
+.version-changelog {
+ border-top: 1px solid var(--border-color);
+ padding-top: 1rem;
+}
+
+.version-changelog h3 {
+ margin: 0 0 0.75rem 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+
+.changelog-link {
+ color: var(--link-color);
+ text-decoration: none;
+}
+
+.changelog-link:hover {
+ text-decoration: underline;
+}
+
+.commit-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ max-height: 250px;
+ overflow-y: auto;
+ font-size: 0.8125rem;
+}
+
+.commit-item {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid var(--border-color);
+ align-items: flex-start;
+}
+
+.commit-item:last-child {
+ border-bottom: none;
+}
+
+.commit-sha {
+ font-family: var(--font-mono);
+ color: var(--link-color);
+ font-size: 0.75rem;
+ flex-shrink: 0;
+ text-decoration: none;
+}
+
+.commit-sha:hover {
+ text-decoration: underline;
+}
+
+.commit-message {
+ color: var(--text-primary);
+ word-break: break-word;
+ line-height: 1.4;
+}
+
+.version-loading,
+.version-no-commits {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ padding: 0.5rem 0;
+}
+
+.version-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ border-top: 1px solid var(--border-color);
+ padding-top: 1rem;
+}
+
+.version-btn {
+ padding: 0.5rem 1rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.15s;
+}
+
+.version-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.version-btn-primary {
+ background: var(--primary);
+ color: white;
+ border: none;
+}
+
+.version-btn-primary:hover:not(:disabled) {
+ background: var(--primary-dark);
+}
@@ -133,3 +133,29 @@ export interface ConversationListUpdate {
conversation?: Conversation;
conversation_id?: string; // For deletes
}
+
+// Version check types
+export interface VersionInfo {
+ current_version: string;
+ current_tag?: string;
+ current_commit?: string;
+ current_commit_time?: string;
+ latest_version?: string;
+ latest_tag?: string;
+ published_at?: string;
+ has_update: boolean; // True if minor version is newer (show upgrade button)
+ should_notify: boolean; // True if should show red dot (newer + 5 days apart)
+ download_url?: string;
+ executable_path?: string;
+ commits?: CommitInfo[];
+ checked_at: string;
+ error?: string;
+ running_under_systemd: boolean; // True if INVOCATION_ID env var is set
+}
+
+export interface CommitInfo {
+ sha: string;
+ message: string;
+ author: string;
+ date: string;
+}
@@ -3,6 +3,7 @@ package version
import (
"encoding/json"
"io/fs"
+ "os"
"runtime/debug"
"shelley.exe.dev/ui"
@@ -25,10 +26,16 @@ type Info struct {
// GetInfo returns build information using runtime/debug.ReadBuildInfo,
// falling back to the embedded build-info.json from the UI build.
+// The SHELLEY_VERSION_OVERRIDE environment variable can override the tag for testing.
func GetInfo() Info {
+ tag := Tag
+ if override := os.Getenv("SHELLEY_VERSION_OVERRIDE"); override != "" {
+ tag = override
+ }
+
info := Info{
Version: Version,
- Tag: Tag,
+ Tag: tag,
}
buildInfo, ok := debug.ReadBuildInfo()