474 lines
13 KiB
Go
474 lines
13 KiB
Go
// upd - download the matching binary from the latest Gitea/GitHub release.
|
|
//
|
|
// upd https://git.micw.org/mike/dns
|
|
// upd https://github.com/sxyazi/yazi --install ~/bin --name yazi,ya
|
|
// upd --all # everything listed in the config file
|
|
// upd --all --check # exit 10 if any update is available
|
|
//
|
|
// A port of the Perl original. The one visible difference is the transport:
|
|
// TLS comes from the Go runtime, so there is no curl, wget or IO::Socket::SSL
|
|
// in the picture and no old system library to trip over. State and cache files
|
|
// keep the Perl layout, so both versions can share ~/.local/state/upd.
|
|
package main
|
|
|
|
import (
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
)
|
|
|
|
// version is a var, not a const, so build.sh can put the built number in via
|
|
// -ldflags "-X main.version=...". The value here only ever shows up in a bare
|
|
// `go build`; the version actually built is the one in version.txt.
|
|
var version = "2.0.0"
|
|
|
|
// Exit codes: 0 ok, 1 usage, 2 error, 10 update available (--check only).
|
|
const (
|
|
exOK = 0
|
|
exUsage = 1
|
|
exError = 2
|
|
exOutdated = 10
|
|
)
|
|
|
|
// Options that belong to a single repository. Anything else is global and
|
|
// applies to every entry of an --all run.
|
|
var specKeys = []string{
|
|
"install", "name", "tag", "asset", "pattern", "os", "arch",
|
|
"forge", "token", "version-flag", "pre",
|
|
}
|
|
|
|
type options struct {
|
|
repo string
|
|
tag string
|
|
install string
|
|
name string
|
|
asset string
|
|
pattern string
|
|
os string
|
|
arch string
|
|
token string
|
|
forge string
|
|
versionFlag string
|
|
timeout int
|
|
config string
|
|
cacert string
|
|
insecure bool
|
|
all bool
|
|
check bool
|
|
pre bool
|
|
force bool
|
|
list bool
|
|
dryRun bool
|
|
quiet bool
|
|
verbose bool
|
|
help bool
|
|
// upd on itself, see selfupdate.go
|
|
showVersion bool
|
|
checkUpdate bool
|
|
doUpdate bool
|
|
updateRefresh bool
|
|
}
|
|
|
|
var (
|
|
opt options
|
|
// Which options the user actually typed. Perl could ask "is it defined?";
|
|
// here the zero value of a flag is indistinguishable from an unset one,
|
|
// and only typed options may override a config file entry.
|
|
given = map[string]bool{}
|
|
)
|
|
|
|
func main() {
|
|
code := run()
|
|
cleanupTemps()
|
|
os.Exit(code)
|
|
}
|
|
|
|
func run() int {
|
|
fs := flag.NewFlagSet("upd", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard) // errors are reported together with the usage text
|
|
|
|
fs.StringVar(&opt.repo, "repo", "", "")
|
|
fs.StringVar(&opt.tag, "tag", "", "")
|
|
for _, n := range []string{"install", "dest", "i"} {
|
|
fs.StringVar(&opt.install, n, "", "")
|
|
}
|
|
fs.StringVar(&opt.name, "name", "", "")
|
|
fs.StringVar(&opt.asset, "asset", "", "")
|
|
fs.StringVar(&opt.pattern, "pattern", "", "")
|
|
fs.StringVar(&opt.os, "os", "", "")
|
|
fs.StringVar(&opt.arch, "arch", "", "")
|
|
fs.StringVar(&opt.token, "token", "", "")
|
|
fs.StringVar(&opt.forge, "forge", "", "")
|
|
fs.StringVar(&opt.versionFlag, "version-flag", "--version", "")
|
|
fs.IntVar(&opt.timeout, "timeout", 30, "")
|
|
fs.StringVar(&opt.config, "config", "", "")
|
|
fs.StringVar(&opt.cacert, "cacert", "", "")
|
|
fs.BoolVar(&opt.insecure, "insecure", false, "")
|
|
fs.BoolVar(&opt.insecure, "k", false, "")
|
|
fs.BoolVar(&opt.all, "all", false, "")
|
|
fs.BoolVar(&opt.check, "check", false, "")
|
|
fs.BoolVar(&opt.pre, "pre", false, "")
|
|
fs.BoolVar(&opt.force, "force", false, "")
|
|
fs.BoolVar(&opt.list, "list", false, "")
|
|
fs.BoolVar(&opt.dryRun, "dry-run", false, "")
|
|
fs.BoolVar(&opt.quiet, "quiet", false, "")
|
|
fs.BoolVar(&opt.verbose, "verbose", false, "")
|
|
fs.BoolVar(&opt.help, "help", false, "")
|
|
fs.BoolVar(&opt.help, "h", false, "")
|
|
fs.BoolVar(&opt.showVersion, "version", false, "")
|
|
fs.BoolVar(&opt.checkUpdate, "check-update", false, "")
|
|
fs.BoolVar(&opt.doUpdate, "update", false, "")
|
|
fs.BoolVar(&opt.updateRefresh, updateRefreshFlag, false, "")
|
|
|
|
if err := fs.Parse(permute(fs, os.Args[1:])); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
usage(os.Stdout)
|
|
return exOK
|
|
}
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n\n", err)
|
|
usage(os.Stderr)
|
|
return exUsage
|
|
}
|
|
if opt.help {
|
|
usage(os.Stdout)
|
|
return exOK
|
|
}
|
|
fs.Visit(func(f *flag.Flag) {
|
|
switch f.Name { // the aliases answer to the name the rest of the code knows
|
|
case "dest", "i":
|
|
given["install"] = true
|
|
default:
|
|
given[f.Name] = true
|
|
}
|
|
})
|
|
|
|
// An explicit choice about certificates switches off the automatic
|
|
// fallback: from here on it is the user's decision, not a guess.
|
|
switch {
|
|
case opt.insecure:
|
|
caCurrent = caNone
|
|
fmt.Fprintf(os.Stderr, "Warning: --insecure, the server certificate is not verified.\n")
|
|
case caCertPath() != "":
|
|
caCurrent = caFile
|
|
}
|
|
verbose("certificates: %v", caCurrent)
|
|
|
|
// Leftovers from an interrupted install must not linger next to the binary.
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
|
go func() {
|
|
<-sig
|
|
cleanupTemps()
|
|
os.Exit(130)
|
|
}()
|
|
|
|
// upd on itself: each of these is a whole run of its own, nothing follows.
|
|
switch {
|
|
case opt.updateRefresh: // the background look, deliberately not in the help
|
|
selfUpdate.refresh()
|
|
return exOK
|
|
case opt.showVersion:
|
|
fmt.Printf("upd %s\n", version)
|
|
return exOK
|
|
case opt.checkUpdate:
|
|
if err := selfUpdate.check(os.Stdout); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
return exError
|
|
}
|
|
return exOK
|
|
case opt.doUpdate:
|
|
if err := selfUpdate.install(os.Stdout); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
return exError
|
|
}
|
|
return exOK
|
|
}
|
|
|
|
specs, err := buildSpecs(fs.Args())
|
|
if err != nil {
|
|
if errors.Is(err, errUsage) {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n\n", err)
|
|
usage(os.Stderr)
|
|
return exUsage
|
|
}
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
return exError
|
|
}
|
|
|
|
var results []result
|
|
for _, spec := range specs {
|
|
res, err := updateOne(spec)
|
|
if err != nil {
|
|
res = result{status: "error", label: spec.label(), msg: oneLine(err.Error())}
|
|
fmt.Fprintf(os.Stderr, "%s: %v\n", res.label, err)
|
|
}
|
|
results = append(results, res)
|
|
}
|
|
|
|
if len(specs) > 1 && !opt.list {
|
|
summary(results)
|
|
}
|
|
// Costs nothing: the hint comes from the note in the cache, and the asking
|
|
// happens once a day at most, in the background.
|
|
if hint := selfUpdate.daily(); hint != "" && !opt.quiet {
|
|
fmt.Fprintln(os.Stderr, hint)
|
|
}
|
|
return worstCode(results)
|
|
}
|
|
|
|
// Go's flag package stops at the first operand, but "upd URL -i ." has to keep
|
|
// working, so operands are moved behind the flags before parsing. A flag that
|
|
// takes a value swallows the argument after it, unless it is a boolean or
|
|
// already carries "=value".
|
|
func permute(fs *flag.FlagSet, args []string) []string {
|
|
var flags, operands []string
|
|
for i := 0; i < len(args); i++ {
|
|
a := args[i]
|
|
if a == "--" {
|
|
operands = append(operands, args[i+1:]...)
|
|
break
|
|
}
|
|
if len(a) < 2 || a[0] != '-' {
|
|
operands = append(operands, a)
|
|
continue
|
|
}
|
|
name := strings.TrimLeft(a, "-")
|
|
inline := strings.Contains(name, "=")
|
|
if inline {
|
|
name, _, _ = strings.Cut(name, "=")
|
|
}
|
|
flags = append(flags, a)
|
|
if inline || isBoolFlag(fs, name) || i+1 >= len(args) {
|
|
continue
|
|
}
|
|
i++
|
|
flags = append(flags, args[i])
|
|
}
|
|
return append(flags, operands...)
|
|
}
|
|
|
|
func isBoolFlag(fs *flag.FlagSet, name string) bool {
|
|
f := fs.Lookup(name)
|
|
if f == nil {
|
|
return false // unknown: let flag.Parse produce the error
|
|
}
|
|
b, ok := f.Value.(interface{ IsBoolFlag() bool })
|
|
return ok && b.IsBoolFlag()
|
|
}
|
|
|
|
var errUsage = errors.New("missing repository URL")
|
|
|
|
func buildSpecs(args []string) ([]*Spec, error) {
|
|
if opt.all {
|
|
path := opt.config
|
|
if path == "" {
|
|
path = configPath()
|
|
}
|
|
specs, err := readConfig(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(specs) == 0 {
|
|
return nil, fmt.Errorf("no entries in %s", path)
|
|
}
|
|
return specs, nil
|
|
}
|
|
|
|
url := opt.repo
|
|
if url == "" && len(args) > 0 {
|
|
url = args[0]
|
|
}
|
|
if url == "" {
|
|
url = os.Getenv("UPD_REPO")
|
|
}
|
|
if url == "" {
|
|
return nil, errUsage
|
|
}
|
|
s := &Spec{Repo: url}
|
|
s.applyGlobals()
|
|
return []*Spec{s}, nil
|
|
}
|
|
|
|
// ==============================================================================
|
|
// Reporting
|
|
// ==============================================================================
|
|
|
|
type result struct {
|
|
status string // ok, current, updated, outdated, listed, dry, error
|
|
label string
|
|
msg string
|
|
}
|
|
|
|
func summary(res []result) {
|
|
rows := res
|
|
if opt.quiet {
|
|
// --quiet is meant for cron: report only what needs attention.
|
|
rows = nil
|
|
for _, r := range res {
|
|
switch r.status {
|
|
case "error", "outdated", "updated":
|
|
rows = append(rows, r)
|
|
}
|
|
}
|
|
}
|
|
if len(rows) == 0 {
|
|
return
|
|
}
|
|
w := 0
|
|
for _, r := range rows {
|
|
if len(r.label) > w {
|
|
w = len(r.label)
|
|
}
|
|
}
|
|
fmt.Print("\nSummary:\n")
|
|
for _, r := range rows {
|
|
fmt.Printf(" %-9s %-*s %s\n", r.status, w, r.label, r.msg)
|
|
}
|
|
}
|
|
|
|
func worstCode(res []result) int {
|
|
for _, r := range res {
|
|
if r.status == "error" {
|
|
return exError
|
|
}
|
|
}
|
|
for _, r := range res {
|
|
if r.status == "outdated" {
|
|
return exOutdated
|
|
}
|
|
}
|
|
return exOK
|
|
}
|
|
|
|
func info(format string, a ...any) {
|
|
if !opt.quiet {
|
|
fmt.Printf(format+"\n", a...)
|
|
}
|
|
}
|
|
|
|
func verbose(format string, a ...any) {
|
|
if opt.verbose {
|
|
fmt.Printf(" "+format+"\n", a...)
|
|
}
|
|
}
|
|
|
|
func oneLine(s string) string {
|
|
return strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
|
|
}
|
|
|
|
func usage(w io.Writer) {
|
|
fmt.Fprintf(w, `upd %s - download the matching binary from the latest Gitea/GitHub release
|
|
|
|
upd [OPTIONS] REPO-URL
|
|
upd --all [OPTIONS]
|
|
|
|
REPO-URL e.g. https://git.micw.org/mike/dns
|
|
https://github.com/sxyazi/yazi
|
|
(or use --repo / $UPD_REPO)
|
|
|
|
Options:
|
|
--install PATH, -i target directory (created if missing), e.g. ~/bin.
|
|
A path whose last segment is one of the binary names,
|
|
or an existing file, means that exact file.
|
|
Also $UPD_INSTALL. Default: the directory of a
|
|
same-named binary in $PATH, else ~/.local/bin
|
|
--name A[,B...] binary name(s) to take out of one asset, e.g.
|
|
"yazi,ya" installs both. Use "SRC:DST" to install
|
|
under a different name (default: repository name)
|
|
--all update every entry of the config file
|
|
--config PATH config file (default: %s)
|
|
--check only report whether an update is available
|
|
--tag VERSION install a specific release ("v1.2.3" or "1.2.3")
|
|
--pre consider prereleases as well
|
|
--asset NAME exact asset name instead of auto-detection
|
|
--pattern REGEX select the asset by regex
|
|
--os OS darwin|linux|windows|freebsd (default: detected)
|
|
--arch ARCH amd64|arm64|386|arm (default: detected)
|
|
--forge NAME github|gitea (default: github for github.com hosts,
|
|
gitea otherwise; needed for GitHub Enterprise)
|
|
--token TOKEN API token for private repositories. Also read from
|
|
$UPD_TOKEN, then $GITHUB_TOKEN/$GH_TOKEN (GitHub)
|
|
or $GITEA_TOKEN (Gitea)
|
|
--version-flag F flag to query the version of a binary installed
|
|
without upd (default: --version)
|
|
--cacert FILE verify against this CA bundle instead of the system
|
|
store (also $UPD_CACERT). Without it, upd uses the
|
|
system store and falls back to its own built-in
|
|
Mozilla CA list if that store does not know the issuer
|
|
--insecure, -k do not verify the server certificate at all
|
|
--force install even if it is already up to date
|
|
--list show releases and their assets
|
|
--dry-run show what would happen, write nothing
|
|
--verbose show asset scoring and cache decisions
|
|
--quiet print errors only
|
|
--timeout SEC network timeout (default: 30)
|
|
--help this help
|
|
--version print the version of upd itself
|
|
--check-update look for a newer release of upd itself
|
|
--update download and install the newest release of upd itself
|
|
|
|
Config file (one line per tool, "#" comments):
|
|
https://git.micw.org/mike/dns install=~/bin
|
|
https://github.com/sxyazi/yazi install=~/bin name=yazi,ya
|
|
Keys: %s
|
|
|
|
State is kept in %s
|
|
Exit codes: 0 ok, 1 usage, 2 error, 10 update available (--check)
|
|
|
|
upd looks for a new release of itself once a day, in the background, and says
|
|
so on stderr. UPD_NO_UPDATE_CHECK=1 turns that off.
|
|
`, version, configPath(), strings.Join(specKeys, ", "), stateDir())
|
|
}
|
|
|
|
func listReleases(rels []Release) {
|
|
for _, r := range rels {
|
|
pre := ""
|
|
if r.Prerelease {
|
|
pre = " [prerelease]"
|
|
}
|
|
published := r.PublishedAt
|
|
if len(published) > 10 {
|
|
published = published[:10]
|
|
}
|
|
fmt.Printf("%-12s %s%s\n", r.TagName, published, pre)
|
|
for _, a := range r.Assets {
|
|
fmt.Printf(" %-32s %s\n", a.Name, humanSize(a.Size))
|
|
}
|
|
}
|
|
}
|
|
|
|
// sortedKeys keeps map iteration out of the output.
|
|
func sortedKeys[V any](m map[string]V) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func homeDir() string {
|
|
if h, err := os.UserHomeDir(); err == nil && h != "" {
|
|
return h
|
|
}
|
|
return "."
|
|
}
|
|
|
|
// expandTilde handles the one form a config file realistically contains.
|
|
func expandTilde(p string) string {
|
|
if p == "~" {
|
|
return homeDir()
|
|
}
|
|
if strings.HasPrefix(p, "~/") {
|
|
return filepath.Join(homeDir(), p[2:])
|
|
}
|
|
return p
|
|
}
|