Files
2026-07-13 13:00:08 +08:00

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)
}
}