initial commit [141.14.140.180,mike]
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user