5d6b8141ec
As of Go 1.16, the same functionality is now provided by package io or package os, and those implementations should be preferred in new code.
425 lines
12 KiB
Go
425 lines
12 KiB
Go
//go:build ignore
|
|
// +build ignore
|
|
|
|
// Get the latest release from a github project
|
|
//
|
|
// If GITHUB_USER and GITHUB_TOKEN are set then these will be used to
|
|
// authenticate the request which is useful to avoid rate limits.
|
|
|
|
package main
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/bzip2"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rclone/rclone/lib/rest"
|
|
"golang.org/x/net/html"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
var (
|
|
// Flags
|
|
install = flag.Bool("install", false, "Install the downloaded package using sudo dpkg -i.")
|
|
extract = flag.String("extract", "", "Extract the named executable from the .tar.gz and install into bindir.")
|
|
bindir = flag.String("bindir", defaultBinDir(), "Directory to install files downloaded with -extract.")
|
|
useAPI = flag.Bool("use-api", false, "Use the API for finding the release instead of scraping the page.")
|
|
// Globals
|
|
matchProject = regexp.MustCompile(`^([\w-]+)/([\w-]+)$`)
|
|
osAliases = map[string][]string{
|
|
"darwin": {"macos", "osx"},
|
|
}
|
|
archAliases = map[string][]string{
|
|
"amd64": {"x86_64"},
|
|
}
|
|
)
|
|
|
|
// A github release
|
|
//
|
|
// Made by pasting the JSON into https://mholt.github.io/json-to-go/
|
|
type Release struct {
|
|
URL string `json:"url"`
|
|
AssetsURL string `json:"assets_url"`
|
|
UploadURL string `json:"upload_url"`
|
|
HTMLURL string `json:"html_url"`
|
|
ID int `json:"id"`
|
|
TagName string `json:"tag_name"`
|
|
TargetCommitish string `json:"target_commitish"`
|
|
Name string `json:"name"`
|
|
Draft bool `json:"draft"`
|
|
Author struct {
|
|
Login string `json:"login"`
|
|
ID int `json:"id"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
GravatarID string `json:"gravatar_id"`
|
|
URL string `json:"url"`
|
|
HTMLURL string `json:"html_url"`
|
|
FollowersURL string `json:"followers_url"`
|
|
FollowingURL string `json:"following_url"`
|
|
GistsURL string `json:"gists_url"`
|
|
StarredURL string `json:"starred_url"`
|
|
SubscriptionsURL string `json:"subscriptions_url"`
|
|
OrganizationsURL string `json:"organizations_url"`
|
|
ReposURL string `json:"repos_url"`
|
|
EventsURL string `json:"events_url"`
|
|
ReceivedEventsURL string `json:"received_events_url"`
|
|
Type string `json:"type"`
|
|
SiteAdmin bool `json:"site_admin"`
|
|
} `json:"author"`
|
|
Prerelease bool `json:"prerelease"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
PublishedAt time.Time `json:"published_at"`
|
|
Assets []struct {
|
|
URL string `json:"url"`
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Label string `json:"label"`
|
|
Uploader struct {
|
|
Login string `json:"login"`
|
|
ID int `json:"id"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
GravatarID string `json:"gravatar_id"`
|
|
URL string `json:"url"`
|
|
HTMLURL string `json:"html_url"`
|
|
FollowersURL string `json:"followers_url"`
|
|
FollowingURL string `json:"following_url"`
|
|
GistsURL string `json:"gists_url"`
|
|
StarredURL string `json:"starred_url"`
|
|
SubscriptionsURL string `json:"subscriptions_url"`
|
|
OrganizationsURL string `json:"organizations_url"`
|
|
ReposURL string `json:"repos_url"`
|
|
EventsURL string `json:"events_url"`
|
|
ReceivedEventsURL string `json:"received_events_url"`
|
|
Type string `json:"type"`
|
|
SiteAdmin bool `json:"site_admin"`
|
|
} `json:"uploader"`
|
|
ContentType string `json:"content_type"`
|
|
State string `json:"state"`
|
|
Size int `json:"size"`
|
|
DownloadCount int `json:"download_count"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
} `json:"assets"`
|
|
TarballURL string `json:"tarball_url"`
|
|
ZipballURL string `json:"zipball_url"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// checks if a path has write access
|
|
func writable(path string) bool {
|
|
return unix.Access(path, unix.W_OK) == nil
|
|
}
|
|
|
|
// Directory to install releases in by default
|
|
//
|
|
// Find writable directories on $PATH. Use $GOPATH/bin if that is on
|
|
// the path and writable or use the first writable directory which is
|
|
// in $HOME or failing that the first writable directory.
|
|
//
|
|
// Returns "" if none of the above were found
|
|
func defaultBinDir() string {
|
|
home := os.Getenv("HOME")
|
|
var (
|
|
bin string
|
|
homeBin string
|
|
goHomeBin string
|
|
gopath = os.Getenv("GOPATH")
|
|
)
|
|
for _, dir := range strings.Split(os.Getenv("PATH"), ":") {
|
|
if writable(dir) {
|
|
if strings.HasPrefix(dir, home) {
|
|
if homeBin != "" {
|
|
homeBin = dir
|
|
}
|
|
if gopath != "" && strings.HasPrefix(dir, gopath) && goHomeBin == "" {
|
|
goHomeBin = dir
|
|
}
|
|
}
|
|
if bin == "" {
|
|
bin = dir
|
|
}
|
|
}
|
|
}
|
|
if goHomeBin != "" {
|
|
return goHomeBin
|
|
}
|
|
if homeBin != "" {
|
|
return homeBin
|
|
}
|
|
return bin
|
|
}
|
|
|
|
// read the body or an error message
|
|
func readBody(in io.Reader) string {
|
|
data, err := io.ReadAll(in)
|
|
if err != nil {
|
|
return fmt.Sprintf("Error reading body: %v", err.Error())
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
// Get an asset URL and name
|
|
func getAsset(project string, matchName *regexp.Regexp) (string, string) {
|
|
url := "https://api.github.com/repos/" + project + "/releases/latest"
|
|
log.Printf("Fetching asset info for %q from %q", project, url)
|
|
user, pass := os.Getenv("GITHUB_USER"), os.Getenv("GITHUB_TOKEN")
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
log.Fatalf("Failed to make http request %q: %v", url, err)
|
|
}
|
|
if user != "" && pass != "" {
|
|
log.Printf("Fetching using GITHUB_USER and GITHUB_TOKEN")
|
|
req.SetBasicAuth(user, pass)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
log.Fatalf("Failed to fetch release info %q: %v", url, err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Printf("Error: %s", readBody(resp.Body))
|
|
log.Fatalf("Bad status %d when fetching %q release info: %s", resp.StatusCode, url, resp.Status)
|
|
}
|
|
var release Release
|
|
err = json.NewDecoder(resp.Body).Decode(&release)
|
|
if err != nil {
|
|
log.Fatalf("Failed to decode release info: %v", err)
|
|
}
|
|
err = resp.Body.Close()
|
|
if err != nil {
|
|
log.Fatalf("Failed to close body: %v", err)
|
|
}
|
|
|
|
for _, asset := range release.Assets {
|
|
//log.Printf("Finding %s", asset.Name)
|
|
if matchName.MatchString(asset.Name) && isOurOsArch(asset.Name) {
|
|
return asset.BrowserDownloadURL, asset.Name
|
|
}
|
|
}
|
|
log.Fatalf("Didn't find asset in info")
|
|
return "", ""
|
|
}
|
|
|
|
// Get an asset URL and name by scraping the downloads page
|
|
//
|
|
// This doesn't use the API so isn't rate limited when not using GITHUB login details
|
|
func getAssetFromReleasesPage(project string, matchName *regexp.Regexp) (assetURL string, assetName string) {
|
|
baseURL := "https://github.com/" + project + "/releases"
|
|
log.Printf("Fetching asset info for %q from %q", project, baseURL)
|
|
base, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
log.Fatalf("URL Parse failed: %v", err)
|
|
}
|
|
resp, err := http.Get(baseURL)
|
|
if err != nil {
|
|
log.Fatalf("Failed to fetch release info %q: %v", baseURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Printf("Error: %s", readBody(resp.Body))
|
|
log.Fatalf("Bad status %d when fetching %q release info: %s", resp.StatusCode, baseURL, resp.Status)
|
|
}
|
|
doc, err := html.Parse(resp.Body)
|
|
if err != nil {
|
|
log.Fatalf("Failed to parse web page: %v", err)
|
|
}
|
|
var walk func(*html.Node)
|
|
walk = func(n *html.Node) {
|
|
if n.Type == html.ElementNode && n.Data == "a" {
|
|
for _, a := range n.Attr {
|
|
if a.Key == "href" {
|
|
if name := path.Base(a.Val); matchName.MatchString(name) && isOurOsArch(name) {
|
|
if u, err := rest.URLJoin(base, a.Val); err == nil {
|
|
if assetName == "" {
|
|
assetName = name
|
|
assetURL = u.String()
|
|
}
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
walk(c)
|
|
}
|
|
}
|
|
walk(doc)
|
|
if assetName == "" || assetURL == "" {
|
|
log.Fatalf("Didn't find URL in page")
|
|
}
|
|
return assetURL, assetName
|
|
}
|
|
|
|
// isOurOsArch returns true if s contains our OS and our Arch
|
|
func isOurOsArch(s string) bool {
|
|
s = strings.ToLower(s)
|
|
check := func(base string, aliases map[string][]string) bool {
|
|
names := []string{base}
|
|
names = append(names, aliases[base]...)
|
|
for _, name := range names {
|
|
if strings.Contains(s, name) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
return check(runtime.GOARCH, archAliases) && check(runtime.GOOS, osAliases)
|
|
}
|
|
|
|
// get a file for download
|
|
func getFile(url, fileName string) {
|
|
log.Printf("Downloading %q from %q", fileName, url)
|
|
|
|
out, err := os.Create(fileName)
|
|
if err != nil {
|
|
log.Fatalf("Failed to open %q: %v", fileName, err)
|
|
}
|
|
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
log.Fatalf("Failed to fetch asset %q: %v", url, err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Printf("Error: %s", readBody(resp.Body))
|
|
log.Fatalf("Bad status %d when fetching %q asset: %s", resp.StatusCode, url, resp.Status)
|
|
}
|
|
|
|
n, err := io.Copy(out, resp.Body)
|
|
if err != nil {
|
|
log.Fatalf("Error while downloading: %v", err)
|
|
}
|
|
|
|
err = resp.Body.Close()
|
|
if err != nil {
|
|
log.Fatalf("Failed to close body: %v", err)
|
|
}
|
|
err = out.Close()
|
|
if err != nil {
|
|
log.Fatalf("Failed to close output file: %v", err)
|
|
}
|
|
|
|
log.Printf("Downloaded %q (%d bytes)", fileName, n)
|
|
}
|
|
|
|
// run a shell command
|
|
func run(args ...string) {
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
log.Fatalf("Failed to run %v: %v", args, err)
|
|
}
|
|
}
|
|
|
|
// Untars fileName from srcFile
|
|
func untar(srcFile, fileName, extractDir string) {
|
|
f, err := os.Open(srcFile)
|
|
if err != nil {
|
|
log.Fatalf("Couldn't open tar: %v", err)
|
|
}
|
|
defer func() {
|
|
err := f.Close()
|
|
if err != nil {
|
|
log.Fatalf("Couldn't close tar: %v", err)
|
|
}
|
|
}()
|
|
|
|
var in io.Reader = f
|
|
|
|
srcExt := filepath.Ext(srcFile)
|
|
if srcExt == ".gz" || srcExt == ".tgz" {
|
|
gzf, err := gzip.NewReader(f)
|
|
if err != nil {
|
|
log.Fatalf("Couldn't open gzip: %v", err)
|
|
}
|
|
in = gzf
|
|
} else if srcExt == ".bz2" {
|
|
in = bzip2.NewReader(f)
|
|
}
|
|
|
|
tarReader := tar.NewReader(in)
|
|
|
|
for {
|
|
header, err := tarReader.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
log.Fatalf("Trouble reading tar file: %v", err)
|
|
}
|
|
name := header.Name
|
|
switch header.Typeflag {
|
|
case tar.TypeReg:
|
|
baseName := filepath.Base(name)
|
|
if baseName == fileName {
|
|
outPath := filepath.Join(extractDir, fileName)
|
|
out, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0777)
|
|
if err != nil {
|
|
log.Fatalf("Couldn't open output file: %v", err)
|
|
}
|
|
n, err := io.Copy(out, tarReader)
|
|
if err != nil {
|
|
log.Fatalf("Couldn't write output file: %v", err)
|
|
}
|
|
if err = out.Close(); err != nil {
|
|
log.Fatalf("Couldn't close output: %v", err)
|
|
}
|
|
log.Printf("Wrote %s (%d bytes) as %q", fileName, n, outPath)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
args := flag.Args()
|
|
if len(args) != 2 {
|
|
log.Fatalf("Syntax: %s <user/project> <name reg exp>", os.Args[0])
|
|
}
|
|
project, nameRe := args[0], args[1]
|
|
if !matchProject.MatchString(project) {
|
|
log.Fatalf("Project %q must be in form user/project", project)
|
|
}
|
|
matchName, err := regexp.Compile(nameRe)
|
|
if err != nil {
|
|
log.Fatalf("Invalid regexp for name %q: %v", nameRe, err)
|
|
}
|
|
|
|
var assetURL, assetName string
|
|
if *useAPI {
|
|
assetURL, assetName = getAsset(project, matchName)
|
|
} else {
|
|
assetURL, assetName = getAssetFromReleasesPage(project, matchName)
|
|
}
|
|
fileName := filepath.Join(os.TempDir(), assetName)
|
|
getFile(assetURL, fileName)
|
|
|
|
if *install {
|
|
log.Printf("Installing %s", fileName)
|
|
run("sudo", "dpkg", "--force-bad-version", "-i", fileName)
|
|
log.Printf("Installed %s", fileName)
|
|
} else if *extract != "" {
|
|
if *bindir == "" {
|
|
log.Fatalf("Need to set -bindir")
|
|
}
|
|
log.Printf("Unpacking %s from %s and installing into %s", *extract, fileName, *bindir)
|
|
untar(fileName, *extract, *bindir+"/")
|
|
}
|
|
}
|