// selfupdate.go - upd updating itself. // // The mechanism is the one dx uses: --check-update looks, --update installs, // and an ordinary run consults a note in the cache once a day and mentions a // newer release on stderr - never asking in the foreground, never asking when // nobody is watching. The work itself goes through upd's own machinery: the // same forge layer, the same retries and CA fallback, the same checksum check // and the same atomic install, so updating upd behaves like updating any other // tool it installs. // // It assumes the layout build.sh produces: one release per version, whose tag // is the bare number (2.0.6, a leading "v" is allowed), holding one asset // "upd--" each - that is, exactly the files from ./bin. package main import ( "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "time" ) // ------------------------------------------------------------ Configuration var selfUpdate = selfUpdater{ repo: "https://git.micw.org/mike/upd", name: "upd", current: version, // from main.go, set by -ldflags every: 24 * time.Hour, quietEnv: "UPD_NO_UPDATE_CHECK", } type selfUpdater struct { repo string // repo URL as in the browser: https://host/owner/repo name string // base name of the assets, "--" is added current string // the running version every time.Duration // how often to look on its own; 0 turns that off quietEnv string // this environment variable set: keep quiet as well } // updateRefreshFlag is the option upd calls itself with, in the background. It // is deliberately absent from the help. const updateRefreshFlag = "update-refresh" // ------------------------------------------------------------ Looking by itself // daily is the hook for the ordinary run. It costs nothing: in the foreground // the network is never touched. What comes back is the line pointing at a new // version - or "", when there is nothing to say. Should the note be older than // `every`, daily starts a background run on the side, whose answer the next // call will find waiting. func (u selfUpdater) daily() string { if u.every <= 0 || os.Getenv(u.quietEnv) != "" || !isTerminal(os.Stderr) { return "" } st := u.loadState() // no file: the zero value, hence due at once if time.Since(st.Checked) >= u.every { // The timestamp moves on before the asking, not after: otherwise two // simultaneous runs start two queries, and a server that is not in the // mood would get a new one on every call. If the note does not stay // put, nothing is asked either - else an unwritable cache directory // would mean one process per call. st.Checked = time.Now() if u.saveState(st) == nil { u.spawnRefresh() } } if st.Latest == "" || compareVer(st.Latest, u.current) <= 0 { return "" } return fmt.Sprintf("Note: %s %s is available, run '%s --update'.", u.name, st.Latest, u.name) } // refresh is the background run: ask, write it down, stay quiet. The writing // down is done by latest; if the query fails, the old state remains. func (u selfUpdater) refresh() { _, _, _ = u.latest() } // spawnRefresh calls upd once more, only to ask, and does not wait. Without a // Wait the child is adopted by init when this process ends - it thus outlives // the call, and the call's output stays untouched by it. func (u selfUpdater) spawnRefresh() { exe, err := os.Executable() if err != nil { return } cmd := exec.Command(exe, "--"+updateRefreshFlag) cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil // everything to /dev/null if cmd.Start() == nil { cmd.Process.Release() } } // --------------------------------------------------------------------- Note // updateState is what is left between two calls: when the last question was // asked and what came of it. type updateState struct { Checked time.Time `json:"checked"` Latest string `json:"latest"` } // The note lives in the cache directory next to the ETag cache, not in the // state directory: if it gets lost, the only cost is asking once too early. // The name cannot collide with a cache file, those are hex digests. func (u selfUpdater) statePath() string { return filepath.Join(cacheDir(), "selfupdate.json") } func (u selfUpdater) loadState() updateState { var st updateState readJSON(u.statePath(), &st) // a missing or broken file counts as none return st } func (u selfUpdater) saveState(st updateState) error { if err := os.MkdirAll(cacheDir(), 0o755); err != nil { return err } return writeJSON(u.statePath(), st) } // ------------------------------------------------------------------ The work // check only looks and touches nothing. func (u selfUpdater) check(w io.Writer) error { _, rel, err := u.latest() if err != nil { return err } if compareVer(rel.TagName, u.current) <= 0 { fmt.Fprintf(w, "%s %s is up to date\n", u.name, u.current) return nil } fmt.Fprintf(w, "%s %s is available, running %s\n", u.name, rel.TagName, u.current) if rel.HTMLURL != "" { fmt.Fprintf(w, " %s\n", rel.HTMLURL) } fmt.Fprintf(w, " run '%s --update' to install it\n", u.name) return nil } // install fetches the newest release and replaces the running file with it. func (u selfUpdater) install(w io.Writer) error { c, rel, err := u.latest() if err != nil { return err } if compareVer(rel.TagName, u.current) <= 0 && !opt.force { fmt.Fprintf(w, "%s %s is up to date\n", u.name, u.current) return nil } want := fmt.Sprintf("%s-%s-%s", u.name, runtime.GOOS, runtime.GOARCH) var src *Asset for i := range rel.Assets { if rel.Assets[i].Name == want { src = &rel.Assets[i] break } } if src == nil { var have []string for _, a := range rel.Assets { have = append(have, a.Name) } return fmt.Errorf("release %s has no %q (only %s)", rel.TagName, want, strings.Join(have, ", ")) } exe, err := os.Executable() if err != nil { return fmt.Errorf("cannot locate the running binary: %w", err) } // An installed upd may be a symlink into ./bin. What should be replaced is // the file behind it, not the link. if real, err := filepath.EvalSymlinks(exe); err == nil { exe = real } // Ask before the download, not after it: a missing write permission ought // to show up before a few megabytes have gone down the wire. if dir := filepath.Dir(exe); !writable(dir) { return fmt.Errorf("no write permission in %s (use sudo, or install upd elsewhere)", dir) } if opt.dryRun { fmt.Fprintf(w, "[dry-run] would install %s %s to %s\n", want, rel.TagName, exe) return nil } tmpdir, err := os.MkdirTemp("", "upd-self-") if err != nil { return err } defer os.RemoveAll(tmpdir) dl := filepath.Join(tmpdir, want) dlURL, dlHdr := c.assetURL(src) hdr := c.authHeaders() for k, v := range dlHdr { hdr[k] = v } fmt.Fprintf(w, "Downloading %s %s (%s) ...\n", want, rel.TagName, humanSize(src.Size)) if err := downloadTo(dl, dlURL, hdr, src.Size); err != nil { return err } if st, err := os.Stat(dl); err == nil && src.Size > 0 && st.Size() < src.Size { return fmt.Errorf("incomplete download: %d of %d bytes", st.Size(), src.Size) } if err := verifyDownload(c, dl, src, rel.Assets, tmpdir); err != nil { return err } if err := os.Chmod(dl, 0o755); err != nil { return err } if err := u.probe(dl, rel.TagName); err != nil { return err } if err := installAtomic(dl, exe); err != nil { return err } stripQuarantine(exe) u.saveState(updateState{Checked: time.Now(), Latest: rel.TagName}) fmt.Fprintf(w, "%s %s -> %s, at %s\n", u.name, u.current, rel.TagName, exe) return nil } // probe runs the freshly fetched binary once. That catches a file that is // truncated, built for the wrong platform, or not executable in the first // place, before it replaces the running one. The version flag is the built-in // one, not spec.versionFlag(): what upd answers to is not the user's to // configure here. func (u selfUpdater) probe(path, tag string) error { got := installedVersion(path, &Spec{VersionFlag: "--version"}) if got == "" { return fmt.Errorf("the downloaded binary does not run, %s left alone", u.name) } if got != normVer(tag) { return fmt.Errorf("the downloaded binary reports %s, expected %s", got, normVer(tag)) } return nil } // latest asks the forge for the newest release, through the same code path an // ordinary run uses, and notes down what came back. func (u selfUpdater) latest() (*forgeCtx, *Release, error) { spec := &Spec{Repo: u.repo} c, err := newForgeCtx(spec) if err != nil { return nil, nil, err } rel, _, err := c.fetchRelease(spec) if err != nil { return nil, nil, err } if rel == nil { // --list on the command line: fetchRelease returns the list return nil, nil, fmt.Errorf("--list cannot be combined with --update") } // Every question that succeeds fills the note - no matter whether it came // from --update, from --check-update or from the background run. u.saveState(updateState{Checked: time.Now(), Latest: rel.TagName}) return c, rel, nil } // ------------------------------------------------------------------ Numbers // compareVer compares two versions component by component, numerically, so // that 2.0.10 lands behind 2.0.9 and not in front of it. A leading "v" does not // count, missing places count as 0 (2.1 == 2.1.0), and a suffix on the number // makes the version older, not newer (2.0.6-rc1 < 2.0.6). The result is the one // of strings.Compare: -1, 0, 1. func compareVer(a, b string) int { as := strings.Split(normVer(a), ".") bs := strings.Split(normVer(b), ".") for i := 0; i < len(as) || i < len(bs); i++ { x, y := "0", "0" if i < len(as) { x = as[i] } if i < len(bs) { y = bs[i] } if c := compareVerPart(x, y); c != 0 { return c } } return 0 } func compareVerPart(a, b string) int { na, ra := splitNum(a) nb, rb := splitNum(b) switch { case na != nb: if na < nb { return -1 } return 1 case ra == rb: return 0 case ra == "": // 2.0.6 is finished, 2.0.6-rc1 is not yet return 1 case rb == "": return -1 } return strings.Compare(ra, rb) } // splitNum separates "10-rc1" into 10 and "-rc1". func splitNum(s string) (int, string) { i := 0 for i < len(s) && s[i] >= '0' && s[i] <= '9' { i++ } n, _ := strconv.Atoi(s[:i]) return n, s[i:] }