422 lines
12 KiB
Go
422 lines
12 KiB
Go
package cli
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/i18n"
|
|
"reasonix/internal/netclient"
|
|
|
|
"golang.org/x/mod/semver"
|
|
)
|
|
|
|
const (
|
|
ghOwner = "esengine"
|
|
ghRepo = "DeepSeek-Reasonix"
|
|
ghAPIReleases = "https://api.github.com/repos/" + ghOwner + "/" + ghRepo + "/releases"
|
|
ghDownloadBase = "https://github.com/" + ghOwner + "/" + ghRepo + "/releases/download"
|
|
upgradeTimeout = 60 * time.Second
|
|
)
|
|
|
|
// ghRelease is the subset of the GitHub release API response we need.
|
|
type ghRelease struct {
|
|
TagName string `json:"tag_name"`
|
|
Assets []ghAsset
|
|
}
|
|
|
|
// ghAsset is a single release asset.
|
|
type ghAsset struct {
|
|
Name string `json:"name"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// upgradeCommand handles `reasonix upgrade` (and `reasonix update`).
|
|
func upgradeCommand(args []string, version string) int {
|
|
fs := flag.NewFlagSet("upgrade", flag.ContinueOnError)
|
|
checkOnly := fs.Bool("check", false, "check for updates without installing")
|
|
force := fs.Bool("force", false, "reinstall even if already on the latest version")
|
|
if err := fs.Parse(args); err != nil {
|
|
return 2
|
|
}
|
|
|
|
// 1. Normalize running version.
|
|
cur, ok := normalizeVersion(version)
|
|
if !ok {
|
|
fmt.Fprintf(os.Stderr, "%s %s\n", i18n.M.ErrorPrefix, i18n.M.UpgradeDevBuild)
|
|
return 1
|
|
}
|
|
|
|
// 2. Build HTTP client using configured proxy.
|
|
cfg, _ := config.Load()
|
|
spec := cfg.NetworkProxySpec()
|
|
c, err := netclient.NewHTTPClient(spec, netclient.TransportOptions{
|
|
ResponseHeaderTimeout: upgradeTimeout,
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s %v\n", i18n.M.ErrorPrefix, err)
|
|
return 1
|
|
}
|
|
|
|
// 3. Fetch latest release from GitHub API.
|
|
fmt.Println(i18n.M.UpgradeChecking)
|
|
rel, err := fetchLatestRelease(c)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s "+i18n.M.UpgradeFetchFailed+"\n", i18n.M.ErrorPrefix, err)
|
|
return 1
|
|
}
|
|
|
|
// 4. Compare versions.
|
|
latest := rel.TagName
|
|
if !strings.HasPrefix(latest, "v") {
|
|
latest = "v" + latest
|
|
}
|
|
if !semver.IsValid(latest) {
|
|
fmt.Fprintf(os.Stderr, "%s "+i18n.M.UpgradeInvalidVersion+"\n", i18n.M.ErrorPrefix, latest)
|
|
return 1
|
|
}
|
|
if semver.Compare(latest, cur) <= 0 {
|
|
if *force {
|
|
fmt.Println(i18n.M.UpgradeForcing)
|
|
} else {
|
|
fmt.Println(i18n.M.UpgradeAlreadyLatest)
|
|
return 0
|
|
}
|
|
} else {
|
|
fmt.Printf(i18n.M.UpgradeAvailableFmt+"\n", cur, latest)
|
|
}
|
|
|
|
if *checkOnly {
|
|
return 0
|
|
}
|
|
|
|
// 5. Find the asset for the current platform.
|
|
base := fmt.Sprintf("reasonix-%s-%s", runtime.GOOS, runtime.GOARCH)
|
|
var asset *ghAsset
|
|
for i := range rel.Assets {
|
|
if strings.HasPrefix(rel.Assets[i].Name, base) {
|
|
asset = &rel.Assets[i]
|
|
break
|
|
}
|
|
}
|
|
if asset == nil {
|
|
fmt.Fprintf(os.Stderr, "%s "+i18n.M.UpgradeNoAssetFmt+"\n", i18n.M.ErrorPrefix, base)
|
|
return 1
|
|
}
|
|
|
|
// 6. Find the checksum URL.
|
|
checksumURL := fmt.Sprintf("%s/%s/SHA256SUMS", ghDownloadBase, rel.TagName)
|
|
|
|
// 7. Download archive.
|
|
fmt.Printf(i18n.M.UpgradeDownloadingFmt+"\n", asset.Name, humanSize(asset.Size))
|
|
archiveData, err := fetchBytes(c, asset.BrowserDownloadURL)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s "+i18n.M.UpgradeDownloadFailed+"\n", i18n.M.ErrorPrefix, err)
|
|
return 1
|
|
}
|
|
|
|
// 8. Verify SHA256 checksum — fail closed: abort on any verification error.
|
|
fmt.Println(i18n.M.UpgradeVerifying)
|
|
checksumData, err := fetchBytes(c, checksumURL)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s "+i18n.M.UpgradeChecksumFailed+"\n", i18n.M.ErrorPrefix, err)
|
|
return 1
|
|
}
|
|
if err := verifyChecksum(archiveData, asset.Name, checksumData); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s %v\n", i18n.M.ErrorPrefix, err)
|
|
return 1
|
|
}
|
|
|
|
// 9. Extract binary from archive.
|
|
binName := "reasonix"
|
|
if runtime.GOOS == "windows" {
|
|
binName = "reasonix.exe"
|
|
}
|
|
binary, err := extractBinary(archiveData, asset.Name, binName)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s "+i18n.M.UpgradeExtractFailed+"\n", i18n.M.ErrorPrefix, err)
|
|
return 1
|
|
}
|
|
|
|
// 10. Replace the running binary.
|
|
fmt.Println(i18n.M.UpgradeApplying)
|
|
if err := replaceBinary(binary); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s "+i18n.M.UpgradeApplyFailed+"\n", i18n.M.ErrorPrefix, err)
|
|
return 1
|
|
}
|
|
|
|
fmt.Println(upgradeSuccessMessage(cur, latest))
|
|
return 0
|
|
}
|
|
|
|
func upgradeSuccessMessage(cur, latest string) string {
|
|
return fmt.Sprintf(i18n.M.UpgradeSuccessFmt, cur, latest)
|
|
}
|
|
|
|
// normalizeVersion returns v as valid semver ("vX.Y.Z") or ok=false for dev.
|
|
func normalizeVersion(v string) (string, bool) {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" || v == "dev" {
|
|
return "", false
|
|
}
|
|
if !strings.HasPrefix(v, "v") {
|
|
v = "v" + v
|
|
}
|
|
if !semver.IsValid(v) {
|
|
return "", false
|
|
}
|
|
return semver.Canonical(v), true
|
|
}
|
|
|
|
// isCLITag reports whether a tag belongs to the CLI release namespace (v*).
|
|
// Tags like "desktop-v1.5.0" or "npm-v1.4.0" are excluded.
|
|
func isCLITag(tag string) bool {
|
|
tag = strings.TrimSpace(tag)
|
|
return len(tag) >= 2 && tag[0] == 'v' && tag[1] >= '0' && tag[1] <= '9'
|
|
}
|
|
|
|
// pickCLIRelease returns the newest CLI-namespace (v*) release from a
|
|
// reverse-chronological list, skipping foreign namespaces ("desktop-v",
|
|
// "npm-v"). Prereleases are kept: only 1.x carries `reasonix upgrade`, and the
|
|
// 1.x line ships as rc on npm @next, so there is no stable user to hold back —
|
|
// the command should always move to the newest 1.x.
|
|
func pickCLIRelease(rels []ghRelease) *ghRelease {
|
|
for i := range rels {
|
|
if isCLITag(rels[i].TagName) {
|
|
return &rels[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fetchLatestRelease queries the GitHub Releases API and returns the newest
|
|
// CLI-namespace (v*) release.
|
|
func fetchLatestRelease(c *http.Client) (*ghRelease, error) {
|
|
req, err := http.NewRequest("GET", ghAPIReleases, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
req.Header.Set("User-Agent", "reasonix-cli")
|
|
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("GitHub API: %s", resp.Status)
|
|
}
|
|
|
|
var rels []ghRelease
|
|
if err := json.NewDecoder(resp.Body).Decode(&rels); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if rel := pickCLIRelease(rels); rel != nil {
|
|
return rel, nil
|
|
}
|
|
return nil, fmt.Errorf("no CLI release (v*) found in recent releases")
|
|
}
|
|
|
|
// fetchBytes GETs a URL fully into memory.
|
|
func fetchBytes(c *http.Client, url string) ([]byte, error) {
|
|
resp, err := c.Get(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
|
|
}
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// verifyChecksum checks that data's SHA256 matches the entry for fileName in
|
|
// the SHA256SUMS-format checksum file.
|
|
func verifyChecksum(data []byte, fileName string, checksumFile []byte) error {
|
|
sum := sha256.Sum256(data)
|
|
got := hex.EncodeToString(sum[:])
|
|
|
|
for _, line := range strings.Split(strings.TrimSpace(string(checksumFile)), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
parts := strings.Fields(line)
|
|
if len(parts) >= 2 && parts[1] == fileName {
|
|
if !strings.EqualFold(parts[0], got) {
|
|
return fmt.Errorf(i18n.M.UpgradeChecksumMismatchFmt, got, parts[0])
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf(i18n.M.UpgradeChecksumNotFoundFmt, fileName)
|
|
}
|
|
|
|
// extractBinary pulls the "reasonix" binary from a .tar.gz or .zip archive.
|
|
func extractBinary(data []byte, archiveName, binaryName string) ([]byte, error) {
|
|
if strings.HasSuffix(archiveName, ".zip") {
|
|
return extractFromZip(data, binaryName)
|
|
}
|
|
return extractFromTarGz(data, binaryName)
|
|
}
|
|
|
|
// extractFromTarGz extracts a named binary from a .tar.gz archive.
|
|
func extractFromTarGz(data []byte, name string) ([]byte, error) {
|
|
gz, err := gzip.NewReader(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer gz.Close()
|
|
tr := tar.NewReader(gz)
|
|
for {
|
|
h, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if h.Typeflag == tar.TypeReg && (h.Name == name || strings.HasSuffix(h.Name, "/"+name)) {
|
|
return io.ReadAll(tr)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("%q not found in archive", name)
|
|
}
|
|
|
|
// extractFromZip extracts a named binary from a .zip archive (Windows).
|
|
func extractFromZip(data []byte, name string) ([]byte, error) {
|
|
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, f := range r.File {
|
|
if f.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
base := filepath.Base(f.Name)
|
|
if base == name {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rc.Close()
|
|
return io.ReadAll(rc)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("%q not found in zip archive", name)
|
|
}
|
|
|
|
// replaceBinary writes newBin to the running executable's path atomically.
|
|
//
|
|
// On Unix this is a simple temp-file + rename. On Windows the running
|
|
// executable is memory-mapped and cannot be overwritten directly, so we
|
|
// rename it aside to .reasonix.old first, then place the new binary.
|
|
// The .old file is cleaned up best-effort (Windows may still hold a lock
|
|
// on it; we hide it in that case).
|
|
func replaceBinary(newBin []byte) error {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return fmt.Errorf("locate executable: %w", err)
|
|
}
|
|
resolved, err := resolveSymlinks(exe)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve symlinks: %w", err)
|
|
}
|
|
|
|
dir := filepath.Dir(resolved)
|
|
base := filepath.Base(resolved)
|
|
tmpPath := filepath.Join(dir, fmt.Sprintf(".%s.new", base))
|
|
|
|
// Write new binary to .new temp file.
|
|
if err := os.WriteFile(tmpPath, newBin, 0o755); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("write temp: %w", err)
|
|
}
|
|
|
|
if runtime.GOOS == "windows" {
|
|
return commitWindows(resolved, tmpPath, base, dir)
|
|
}
|
|
|
|
// Unix: atomic rename .new → target.
|
|
if err := os.Rename(tmpPath, resolved); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("rename: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// commitWindows performs the two-phase rename on Windows:
|
|
// 1. Rename running exe → .old (allowed while running)
|
|
// 2. Rename .new → target
|
|
// 3. Best-effort remove .old (hide if still locked)
|
|
func commitWindows(target, newPath, base, dir string) error {
|
|
oldPath := filepath.Join(dir, fmt.Sprintf(".%s.old", base))
|
|
|
|
// Remove any leftover .old from a previous update.
|
|
_ = os.Remove(oldPath)
|
|
|
|
// Move the running executable aside.
|
|
if err := os.Rename(target, oldPath); err != nil {
|
|
os.Remove(newPath)
|
|
return fmt.Errorf("rename running exe aside: %w", err)
|
|
}
|
|
|
|
// Move the new binary into place.
|
|
if err := os.Rename(newPath, target); err != nil {
|
|
// Rollback: try to restore the old binary.
|
|
if rerr := os.Rename(oldPath, target); rerr != nil {
|
|
return fmt.Errorf("replace failed (%v); rollback also failed: %w", err, rerr)
|
|
}
|
|
return fmt.Errorf("rename new binary: %w", err)
|
|
}
|
|
|
|
// Best-effort cleanup of the old binary.
|
|
if err := os.Remove(oldPath); err != nil {
|
|
// Windows may hold a lock; hide the file so it doesn't clutter the dir.
|
|
hideFileWindows(oldPath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveSymlinks follows symlinks; falls back to the original path on error.
|
|
func resolveSymlinks(p string) (string, error) {
|
|
r, err := filepath.EvalSymlinks(p)
|
|
if err != nil {
|
|
return p, nil
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// humanSize returns a human-readable byte size.
|
|
func humanSize(b int64) string {
|
|
const (
|
|
_KiB = 1024
|
|
_MiB = 1024 * _KiB
|
|
)
|
|
switch {
|
|
case b >= _MiB:
|
|
return fmt.Sprintf("%.1f MiB", float64(b)/float64(_MiB))
|
|
case b >= _KiB:
|
|
return fmt.Sprintf("%.1f KiB", float64(b)/float64(_KiB))
|
|
default:
|
|
return fmt.Sprintf("%d B", b)
|
|
}
|
|
}
|