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