Files
upd/main.go
T

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
}