@@ -7,10 +7,13 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
+ "errors"
"fmt"
"io"
+ "io/fs"
"net/http"
"os"
+ "os/exec"
"runtime"
"sort"
"strings"
@@ -422,10 +425,101 @@ func (vc *VersionChecker) DoUpgrade(ctx context.Context) error {
}
// Apply the update
- if err := selfupdate.Apply(bytes.NewReader(binaryData), selfupdate.Options{}); err != nil {
+ err = selfupdate.Apply(bytes.NewReader(binaryData), selfupdate.Options{})
+ if err == nil {
+ return nil
+ }
+
+ // Check if the error is permission-related and sudo is available
+ if !isPermissionError(err) {
return fmt.Errorf("failed to apply update: %w", err)
}
+ if !isSudoAvailable() {
+ return fmt.Errorf("failed to apply update (no write permission and sudo not available): %w", err)
+ }
+
+ // Fall back to sudo-based upgrade
+ return vc.doSudoUpgrade(binaryData)
+}
+
+// isPermissionError checks if the error is related to file permissions.
+func isPermissionError(err error) bool {
+ return errors.Is(err, fs.ErrPermission) || errors.Is(err, os.ErrPermission)
+}
+
+// doSudoUpgrade performs the upgrade using sudo when the binary isn't writable.
+func (vc *VersionChecker) doSudoUpgrade(binaryData []byte) error {
+ // Get the path to the current executable
+ exePath, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("failed to get executable path: %w", err)
+ }
+
+ // Write the new binary to a temp file
+ tmpFile, err := os.CreateTemp("", "shelley-upgrade-*")
+ if err != nil {
+ return fmt.Errorf("failed to create temp file: %w", err)
+ }
+ tmpPath := tmpFile.Name()
+ defer os.Remove(tmpPath)
+
+ if _, err := tmpFile.Write(binaryData); err != nil {
+ tmpFile.Close()
+ return fmt.Errorf("failed to write temp file: %w", err)
+ }
+ if err := tmpFile.Close(); err != nil {
+ return fmt.Errorf("failed to close temp file: %w", err)
+ }
+
+ // Make the temp file executable
+ if err := os.Chmod(tmpPath, 0o755); err != nil {
+ return fmt.Errorf("failed to chmod temp file: %w", err)
+ }
+
+ // Use sudo to install the new binary. We can't cp over a running binary ("Text file busy"),
+ // so we cp to a .new file and then mv (which is atomic and works on running binaries).
+ newPath := exePath + ".new"
+ oldPath := exePath + ".old"
+
+ // Copy new binary to .new location
+ cmd := exec.Command("sudo", "cp", tmpPath, newPath)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("failed to copy new binary: %w: %s", err, output)
+ }
+
+ // Copy ownership and permissions from original
+ cmd = exec.Command("sudo", "chown", "--reference="+exePath, newPath)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ exec.Command("sudo", "rm", "-f", newPath).Run()
+ return fmt.Errorf("failed to set ownership: %w: %s", err, output)
+ }
+
+ cmd = exec.Command("sudo", "chmod", "--reference="+exePath, newPath)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ exec.Command("sudo", "rm", "-f", newPath).Run()
+ return fmt.Errorf("failed to set permissions: %w: %s", err, output)
+ }
+
+ // Rename old binary to .old (backup)
+ cmd = exec.Command("sudo", "mv", exePath, oldPath)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ exec.Command("sudo", "rm", "-f", newPath).Run()
+ return fmt.Errorf("failed to backup old binary: %w: %s", err, output)
+ }
+
+ // Rename .new to target (atomic replacement)
+ cmd = exec.Command("sudo", "mv", newPath, exePath)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ // Try to restore the old binary
+ exec.Command("sudo", "mv", oldPath, exePath).Run()
+ return fmt.Errorf("failed to install new binary: %w: %s", err, output)
+ }
+
+ // Remove the backup
+ cmd = exec.Command("sudo", "rm", "-f", oldPath)
+ cmd.Run() // Best effort, ignore errors
+
return nil
}
@@ -3,8 +3,11 @@ package server
import (
"context"
"encoding/json"
+ "errors"
+ "io/fs"
"net/http"
"net/http/httptest"
+ "os"
"testing"
"time"
)
@@ -192,3 +195,46 @@ func TestIndexOf(t *testing.T) {
})
}
}
+
+func TestIsPermissionError(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ expected bool
+ }{
+ {
+ name: "fs.ErrPermission",
+ err: fs.ErrPermission,
+ expected: true,
+ },
+ {
+ name: "os.ErrPermission",
+ err: os.ErrPermission,
+ expected: true,
+ },
+ {
+ name: "wrapped fs.ErrPermission",
+ err: errors.Join(errors.New("outer"), fs.ErrPermission),
+ expected: true,
+ },
+ {
+ name: "other error",
+ err: errors.New("some other error"),
+ expected: false,
+ },
+ {
+ name: "nil error",
+ err: nil,
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := isPermissionError(tt.err)
+ if result != tt.expected {
+ t.Errorf("isPermissionError(%v) = %v, want %v", tt.err, result, tt.expected)
+ }
+ })
+ }
+}
@@ -73,21 +73,13 @@ function VersionModal({ isOpen, onClose, versionInfo, isLoading }: VersionModalP
const formatDateTime = (dateStr: string) => {
const date = new Date(dateStr);
- return date.toLocaleDateString(undefined, {
+ return date.toLocaleString(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",
+ timeZoneName: "short",
});
};
@@ -134,7 +126,9 @@ function VersionModal({ isOpen, onClose, versionInfo, isLoading }: VersionModalP
<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>
+ <span className="version-date">
+ ({formatDateTime(versionInfo.published_at)})
+ </span>
)}
</div>
)}