From 682ac2a4a4916c2c20adb8e051443ddff0ae18f3 Mon Sep 17 00:00:00 2001 From: Michael Wesemann Date: Tue, 11 Aug 2026 15:07:21 +0200 Subject: [PATCH] [mike@mwxm4] --- README.md | 62 ++++++ alias.go | 2 +- commands.go | 10 + completion.go | 1 + main.go | 48 +++++ selfupdate.go | 484 +++++++++++++++++++++++++++++++++++++++++++++ selfupdate_test.go | 190 ++++++++++++++++++ version.txt | 2 +- 8 files changed, 797 insertions(+), 2 deletions(-) create mode 100644 selfupdate.go create mode 100644 selfupdate_test.go diff --git a/README.md b/README.md index b236567..3c57e6f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ directory. Go port of the original Perl `mgsh` (`mgsh.perl`). - [Public mirror (`pushremote`)](#public-mirror-pushremote) · [Deleting a mirror (`deleteremote`)](#deleting-a-mirror-deleteremote) · [Releases](#releases) +- [Updating itself](#updating-itself) - [Configuration](#configuration) · [Settings reference](#settings-reference) · [Per-project configuration](#per-project-configuration) - [Git server layout](#git-server-layout) @@ -148,6 +149,7 @@ Run `help` for the full list. Highlights: | `unalias ` | remove a command alias | | `config [-k]` | show the effective configuration and its sources | | `rescan` | reload the config, refresh the cached repo list | +| `update [-c]` | update mgsh to the newest release (`-c`: only look) | | `!` | run `` in the shell | Commands that touch a repository (`push`, `pull`, `log`, `diff`, `tag`, `dist`, @@ -498,6 +500,66 @@ This is where the providers stop resembling each other, and mgsh papers over it: The GitLab route needs the package registry enabled on the project — it is on by default, but a self-hosted instance can turn it off. +## Updating itself + +``` +update -c # only look +update # fetch and replace +``` + +and from outside the shell, `mgsh update` / `mgsh --update` — the dashed +spelling is the one that also works before mgsh is configured, where every +other command exits with "not configured". + +mgsh fetches the newest release from +[git.micw.org/mike/mgsh](https://git.micw.org/mike/mgsh) — the URL sits fixed in +the program, there is nothing to configure. What it needs is one release per +version, whose tag is the bare number (`4.1.0`), with the files from `./bin` as +its assets; the one looked for is the one matching `GOOS`/`GOARCH` of this +machine. That is exactly what `./build.sh` produces and `release` publishes, so +`./build.sh && release 4.1.0` is the whole publishing side. + +What gets replaced is the running file itself. If the `mgsh` that was called is +a symlink — say `~/bin/mgsh` pointing at `~/src/mgsh/bin/mgsh-darwin-arm64` — +the target behind it is renewed, not the link. Before the swap, what was freshly +fetched is called once with `--version`; if it does not report the expected +number, everything stays as it was. The swap itself is a `rename` within the +same directory, hence atomic: either the old file or the new one, never half of +one. If the binary lies somewhere you may not write to (`/usr/local/bin`), +`update` says so and does nothing — then `sudo`. + +### Once a day, by itself + +Without being asked, mgsh looks once a day and says so on stderr — at the start +of an interactive session, and after the output of a one-shot command: + +``` +mgsh 4.1.0 is available, run 'mgsh --update' +``` + +The run in the foreground never touches the network for this. It only reads a +note — `~/Library/Caches/mgsh/update.json`, on Linux `~/.cache/mgsh/update.json` +— and when that one is older than a day, it starts `mgsh --update-refresh` on +the side: the same binary once more, detached, without output, only to ask. +Nobody waits for its answer; it will be in the note at the next call. mgsh +thereby stays exactly as fast as before, even when the server happens to be +silent. + +The timestamp moves on *before* the asking. Two simultaneous runs therefore +start one query, not two, and a server that does not answer is asked again +tomorrow rather than on every call. If the note cannot be written, the question +is dropped entirely — otherwise a write-protected cache directory would mean one +process per call. + +Asking and speaking happen only when stderr hangs on a terminal. In a pipe, in a +script and under cron there is quiet, and `MGSH_NO_UPDATE_CHECK=1` turns it off +altogether. + +`selfupdate.go` is a copy from [dx](https://git.micw.org/mike/dx) and hangs on +nothing in the rest of mgsh: standard library only, every name it brings starts +with `selfUpdate` or `update`, and the block at the top of the file is all there +is to adjust when it moves on to the next program. + ## Configuration mgsh has **no built-in defaults**. Settings are resolved in three steps, each diff --git a/alias.go b/alias.go index febcc6e..8795904 100644 --- a/alias.go +++ b/alias.go @@ -47,7 +47,7 @@ var builtinCmds = map[string]bool{ "pushremote": true, "deleteremote": true, "overview": true, "archive": true, "init": true, "login": true, "cd": true, "checkout": true, "clone": true, "cloneall": true, "count": true, "tag": true, "alias": true, - "unalias": true, "config": true, "release": true, + "unalias": true, "config": true, "release": true, "update": true, } func isBuiltin(name string) bool { return builtinCmds[name] } diff --git a/commands.go b/commands.go index 854bb44..b569962 100644 --- a/commands.go +++ b/commands.go @@ -196,6 +196,15 @@ func runCommandDepth(line string, depth int) bool { } showConfig() + case "update": // replace this binary with the newest release (-c: only look) + run := selfUpdate.install + if opt["c"] { + run = selfUpdate.check + } + if err := run(os.Stdout); err != nil { + errorln("update: " + err.Error()) + } + case "rescan": // reload the configuration and the cached server repo list reloadConfig() rescanServer() @@ -802,6 +811,7 @@ var helpItems = []struct{ cmd, desc string }{ {"unalias ", "remove an alias"}, {"config [-k]", "show effective configuration (-k: list all setting names)"}, {"rescan", "reload config and refresh cached server repository list"}, + {"update [-c]", "update mgsh to the newest release (-c: only look)"}, {"!", "run in the shell"}, {"quit", "exit mgsh"}, } diff --git a/completion.go b/completion.go index 74e22b7..9c11557 100644 --- a/completion.go +++ b/completion.go @@ -85,6 +85,7 @@ func builtinCompleter() *readline.PrefixCompleter { readline.PcItem("unalias", readline.PcItemDynamic(dynAliasNames)), readline.PcItem("config", readline.PcItem("-k")), readline.PcItem("rescan"), + readline.PcItem("update", readline.PcItem("-c")), readline.PcItem("help"), readline.PcItem("quit"), readline.PcItem("exit"), diff --git a/main.go b/main.go index a8baad8..da3c2a4 100644 --- a/main.go +++ b/main.go @@ -45,6 +45,13 @@ var ( ) func main() { + // Answered before setup(), which exits when nothing is configured yet: an + // update has to work on a machine that has never run mgsh, and `--version` + // is what the freshly downloaded binary is probed with, right there. + if updateFlags() { + return + } + setup() useColor = readline.IsTerminal(int(os.Stdout.Fd())) @@ -68,12 +75,52 @@ func main() { } updateDirState() runCommand(cmdline) + updateNote() // after the output: a footer, not a headline return } + updateNote() // before the prompt: a session starts here, not when it ends runInteractive() } +// updateFlags answers the self-update options and reports whether it did. They +// are deliberately spelled with dashes and kept out of parseArgs: `mgsh update` +// is the command for everyday use, and these are what works when there is no +// configuration to read yet. +func updateFlags() bool { + if len(os.Args) < 2 { + return false + } + switch os.Args[1] { + case "--version": + fmt.Printf("mgsh %s\n", VERSION) + case "--update": + if err := selfUpdate.install(os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "mgsh: %v\n", err) + os.Exit(1) + } + case "--check-update": + if err := selfUpdate.check(os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "mgsh: %v\n", err) + os.Exit(1) + } + case updateRefreshFlag: // the background run, not in the help + selfUpdate.refresh() + default: + return false + } + return true +} + +// updateNote prints the once-a-day hint, when there is one. It costs nothing: +// the line comes from the note in the cache directory, and the asking behind it +// happens in the background, at most once a day. +func updateNote() { + if hint := selfUpdate.daily(); hint != "" { + fmt.Fprintln(os.Stderr, col(cDark, hint)) + } +} + // runInteractive drives the colored, history- and completion-enabled REPL. func runInteractive() { home, _ := os.UserHomeDir() @@ -148,6 +195,7 @@ func parseArgs() (int, string, bool) { "push": 1, "pushremote": 1, "deleteremote": 1, "list": 1, "tag": 1, "archive": 1, "show": 1, "pull": 1, "fetch": 1, "status": 1, "diff": 1, "overview": 1, "config": 1, "count": 1, "login": 1, "cloneall": 1, "release": 1, + "update": 1, } if c, ok := cls[a0]; ok { return c, strings.Join(os.Args[1:], " "), false diff --git a/selfupdate.go b/selfupdate.go new file mode 100644 index 0000000..dafbcc9 --- /dev/null +++ b/selfupdate.go @@ -0,0 +1,484 @@ +// selfupdate.go — updating oneself from the releases of a Gitea instance. +// +// The file is meant to be copied: take it into another program, adjust the +// configuration block below, hang `--update` and `--check-update` into the +// options — done. It needs nothing but the standard library, and apart from +// that block it brings no names that do not begin with "selfUpdate" or +// "update". +// +// It assumes the layout build.sh produces: one release per version, whose tag +// is the bare number (4.0.64, a leading "v" is allowed), holding one asset +// "--" each — that is, exactly the files from ./bin. Under +// /api/v1/repos///releases/latest Gitea hands out the newest +// release that is neither a draft nor a prerelease; GitHub speaks the same +// route with different field names and is therefore not covered. +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +// ------------------------------------------------------------ Configuration + +var selfUpdate = selfUpdater{ + repo: "https://git.micw.org/mike/mgsh", + asset: "mgsh", + current: VERSION, // from main.go, set by -ldflags + verify: []string{"--version"}, + every: 24 * time.Hour, + quietEnv: "MGSH_NO_UPDATE_CHECK", +} + +type selfUpdater struct { + repo string // repo URL as in the browser: https://host/owner/repo + asset string // base name of the assets, "--" is added + current string // the running version + verify []string // trial run of the download; empty skips it + 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 the program 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 of the program. 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; what it +// looks like is up to the caller. 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) != "" || !updateOnTerminal() { + 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 == "" || updateCompare(st.Latest, u.current) <= 0 { + return "" + } + return fmt.Sprintf("%s %s is available, run '%s --update'", u.asset, st.Latest, u.asset) +} + +// 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 this program 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() + } +} + +// The hint is meant for the person sitting there. Running in a pipe, in a +// script or under cron, the program neither asks nor says anything. +func updateOnTerminal() bool { + st, err := os.Stderr.Stat() + return err == nil && st.Mode()&os.ModeCharDevice != 0 +} + +// --------------------------------------------------------------------- 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, not in the configuration: if it gets +// lost, the only cost is asking once too early. +func (u selfUpdater) statePath() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(dir, u.asset, "update.json"), nil +} + +func (u selfUpdater) loadState() updateState { + var st updateState + path, err := u.statePath() + if err != nil { + return st + } + b, err := os.ReadFile(path) + if err != nil { + return st + } + json.Unmarshal(b, &st) // a broken file counts as none + return st +} + +func (u selfUpdater) saveState(st updateState) error { + path, err := u.statePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + b, err := json.Marshal(st) + if err != nil { + return err + } + // By way of a file alongside, so that a simultaneous run never comes upon + // half a JSON. + tmp := path + ".new" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return err + } + return nil +} + +// ------------------------------------------------------------------ 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 updateCompare(rel.TagName, u.current) <= 0 { + fmt.Fprintf(w, "%s %s is up to date\n", u.asset, u.current) + return nil + } + fmt.Fprintf(w, "%s %s is available, running %s\n %s\n run '%s --update' to install it\n", + u.asset, rel.TagName, u.current, rel.HTMLURL, u.asset) + return nil +} + +// install fetches the newest release and replaces the running file with it. +func (u selfUpdater) install(w io.Writer) error { + rel, err := u.latest() + if err != nil { + return err + } + if updateCompare(rel.TagName, u.current) <= 0 { + fmt.Fprintf(w, "%s %s is up to date\n", u.asset, u.current) + return nil + } + + want := fmt.Sprintf("%s-%s-%s", u.asset, runtime.GOOS, runtime.GOARCH) + var src *updateAsset + for i := range rel.Assets { + if rel.Assets[i].Name == want { + src = &rel.Assets[i] + break + } + } + if src == nil { + names := make([]string, len(rel.Assets)) + for i, a := range rel.Assets { + names[i] = a.Name + } + return fmt.Errorf("release %s has no %q (only %s)", rel.TagName, want, strings.Join(names, ", ")) + } + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("cannot locate the running binary: %w", err) + } + // An installed mgsh is often 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 + } + mode := os.FileMode(0o755) + if st, err := os.Stat(exe); err == nil { + mode = st.Mode().Perm() + } + + fmt.Fprintf(w, "downloading %s %s (%s)\n", want, rel.TagName, updateSize(src.Size)) + tmp, err := u.download(src, exe, mode) + if err != nil { + return err + } + defer os.Remove(tmp) // only bites when the renaming below falls through + + if err := u.probe(tmp, rel.TagName); err != nil { + return err + } + if err := updateReplace(tmp, exe); err != nil { + return err + } + + fmt.Fprintf(w, "%s %s → %s, at %s\n", u.asset, u.current, rel.TagName, exe) + return nil +} + +func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode) (string, error) { + // The new file comes into being next to the old one: same filesystem, so + // the renaming at the end is one atomic step and not half a copy. It also + // comes into being before the first byte — a missing write permission ought + // to show up before a few megabytes have gone down the wire. + dir := filepath.Dir(exe) + f, err := os.CreateTemp(dir, "."+filepath.Base(exe)+".new") + if err != nil { + var pe *os.PathError // the path is in the message already + if errors.As(err, &pe) { + err = pe.Err + } + return "", fmt.Errorf("cannot write to %s: %w", dir, err) + } + tmp := f.Name() + + resp, err := updateGet(context.Background(), a.URL) + if err != nil { + f.Close() + os.Remove(tmp) + return "", err + } + defer resp.Body.Close() + + n, err := io.Copy(f, resp.Body) + if cerr := f.Close(); err == nil { + err = cerr + } + if err == nil && a.Size > 0 && n != a.Size { + err = fmt.Errorf("got %d of %d bytes from %s", n, a.Size, a.URL) + } + if err == nil { + err = os.Chmod(tmp, mode) + } + if err != nil { + os.Remove(tmp) + return "", err + } + return tmp, nil +} + +// probe calls the freshly fetched runner 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. +func (u selfUpdater) probe(path, tag string) error { + if len(u.verify) == 0 { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, path, u.verify...).CombinedOutput() + if err != nil { + return fmt.Errorf("the downloaded binary does not run: %w", err) + } + if !strings.Contains(string(out), strings.TrimPrefix(tag, "v")) { + return fmt.Errorf("the downloaded binary reports %q, expected %s", + strings.TrimSpace(string(out)), tag) + } + return nil +} + +// updateReplace swaps the running file for the new one. +func updateReplace(tmp, exe string) error { + if err := os.Rename(tmp, exe); err == nil { + return nil + } + // Unix overwrites the file of a running program without complaint, Windows + // does not: there the old one has to be got out of the way first. Deleting + // it becomes possible when this process ends at the earliest — so the + // tidying up is allowed to fail. + old := exe + ".old" + os.Remove(old) + if err := os.Rename(exe, old); err != nil { + return fmt.Errorf("cannot replace %s: %w", exe, err) + } + if err := os.Rename(tmp, exe); err != nil { + os.Rename(old, exe) // back to how it was + return fmt.Errorf("cannot replace %s: %w", exe, err) + } + os.Remove(old) + return nil +} + +// -------------------------------------------------------------------- Gitea + +type updateRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Assets []updateAsset `json:"assets"` +} + +type updateAsset struct { + Name string `json:"name"` + Size int64 `json:"size"` + URL string `json:"browser_download_url"` +} + +func (u selfUpdater) latest() (updateRelease, error) { + base, err := u.apiBase() + if err != nil { + return updateRelease{}, err + } + // The question is a small one; if it hangs, it does not hang for long. The + // generous time limit of updateClient is meant for the download. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := updateGet(ctx, base+"/releases/latest") + if err != nil { + return updateRelease{}, err + } + defer resp.Body.Close() + + var rel updateRelease + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return updateRelease{}, fmt.Errorf("unexpected answer from %s: %w", base, err) + } + if rel.TagName == "" { + return updateRelease{}, fmt.Errorf("%s has no releases", u.repo) + } + + // 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 rel, nil +} + +// apiBase turns https://host/owner/repo into the API root of the repo. +func (u selfUpdater) apiBase() (string, error) { + bad := fmt.Errorf("repo %q: expected https://host/owner/repo", u.repo) + + ref, err := url.Parse(strings.TrimSuffix(strings.TrimSuffix(u.repo, "/"), ".git")) + if err != nil || ref.Host == "" { + return "", bad + } + parts := strings.Split(strings.Trim(ref.Path, "/"), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", bad + } + return fmt.Sprintf("%s://%s/api/v1/repos/%s/%s", ref.Scheme, ref.Host, parts[0], parts[1]), nil +} + +// One time limit for all of it: the look costs a few hundred milliseconds, the +// download a few megabytes — both may hang, but not forever. +var updateClient = &http.Client{Timeout: 5 * time.Minute} + +func updateGet(ctx context.Context, target string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "selfupdate.go (+"+runtime.GOOS+"/"+runtime.GOARCH+")") + + resp, err := updateClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("GET %s: %s", target, resp.Status) + } + return resp, nil +} + +// ------------------------------------------------------------------ Numbers + +// updateCompare compares two versions component by component, numerically, so +// that 2.1.10 lands behind 2.1.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.1.6-rc1 < 2.1.6). The result is the one +// of strings.Compare: -1, 0, 1. +func updateCompare(a, b string) int { + as := strings.Split(strings.TrimPrefix(a, "v"), ".") + bs := strings.Split(strings.TrimPrefix(b, "v"), ".") + + 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 := updateComparePart(x, y); c != 0 { + return c + } + } + return 0 +} + +func updateComparePart(a, b string) int { + na, ra := updateSplitNum(a) + nb, rb := updateSplitNum(b) + switch { + case na != nb: + if na < nb { + return -1 + } + return 1 + case ra == rb: + return 0 + case ra == "": // 2.1.6 is finished, 2.1.6-rc1 is not yet + return 1 + case rb == "": + return -1 + } + return strings.Compare(ra, rb) +} + +// updateSplitNum separates "10-rc1" into 10 and "-rc1". +func updateSplitNum(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:] +} + +// updateSize is deliberately a small formatting of its own and not humanSize +// from colors.go — the file is meant to stand on its own. +func updateSize(b int64) string { + const k = 1024 + switch { + case b > k*k: + return fmt.Sprintf("%.1f MB", float64(b)/k/k) + case b > k: + return fmt.Sprintf("%.1f KB", float64(b)/k) + default: + return fmt.Sprintf("%d B", b) + } +} diff --git a/selfupdate_test.go b/selfupdate_test.go new file mode 100644 index 0000000..6119aea --- /dev/null +++ b/selfupdate_test.go @@ -0,0 +1,190 @@ +package main + +// selfupdate_test.go — the parts of selfupdate.go that can be checked without +// replacing the running binary: the version arithmetic, the URL it derives, and +// what it makes of a Gitea release. +// +// selfupdate.go itself is a file copied between programs and stays as it is; +// the tests live here so the copy keeps working when it lands in the next one. + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// cacheInTemp points os.UserCacheDir at a temporary directory, so the note +// selfupdate writes never touches the real one. +func cacheInTemp(t *testing.T) { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) // darwin: ~/Library/Caches + t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, "xdg")) // linux +} + +// fakeGitea serves one /releases/latest answer, the way Gitea does. +func fakeGitea(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/mike/mgsh/releases/latest" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +func testUpdater(srv *httptest.Server, current string) selfUpdater { + return selfUpdater{ + repo: srv.URL + "/mike/mgsh", + asset: "mgsh", + current: current, + every: 0, // no background run from a test + quietEnv: "MGSH_NO_UPDATE_CHECK", + } +} + +// TestUpdateCompare is the one piece of arithmetic in the file, and the reason +// it exists: a plain string comparison puts 4.0.9 after 4.0.10 and would offer +// an update backwards forever. +func TestUpdateCompare(t *testing.T) { + for _, c := range []struct { + a, b string + want int + }{ + {"4.0.10", "4.0.9", 1}, // numerically, not alphabetically + {"4.0.9", "4.0.10", -1}, + {"4.0.64", "4.0.64", 0}, + {"v4.1.0", "4.0.64", 1}, // a leading v does not count + {"4.1", "4.1.0", 0}, // missing places are zeroes + {"4.2", "4.1.9", 1}, + {"4.0.64-rc1", "4.0.64", -1}, // a suffix is not yet the release + {"4.0.64", "4.0.64-rc1", 1}, + {"4.0.64-rc2", "4.0.64-rc1", 1}, + {"5.0.0", "4.99.99", 1}, + } { + if got := updateCompare(c.a, c.b); got != c.want { + t.Errorf("updateCompare(%q, %q) = %d, want %d", c.a, c.b, got, c.want) + } + } +} + +// TestUpdateAPIBase: the browser URL of the repo is all the file is configured +// with, so everything depends on it becoming the right API root. +func TestUpdateAPIBase(t *testing.T) { + for _, c := range []struct{ repo, want string }{ + {"https://git.micw.org/mike/mgsh", "https://git.micw.org/api/v1/repos/mike/mgsh"}, + {"https://git.micw.org/mike/mgsh/", "https://git.micw.org/api/v1/repos/mike/mgsh"}, + {"https://git.micw.org/mike/mgsh.git", "https://git.micw.org/api/v1/repos/mike/mgsh"}, + {"http://localhost:3000/mike/mgsh", "http://localhost:3000/api/v1/repos/mike/mgsh"}, + } { + got, err := selfUpdater{repo: c.repo}.apiBase() + if err != nil || got != c.want { + t.Errorf("apiBase(%q) = %q, %v, want %q", c.repo, got, err, c.want) + } + } + // anything that is not host/owner/repo has to say so rather than build a + // URL that 404s later + for _, bad := range []string{"", "git.micw.org/mike/mgsh", "https://git.micw.org/mike", "https://git.micw.org/a/b/c"} { + if _, err := (selfUpdater{repo: bad}).apiBase(); err == nil { + t.Errorf("apiBase(%q) accepted a malformed repo URL", bad) + } + } +} + +// TestSelfUpdateCheck covers both answers of `update -c`, and that a successful +// look leaves the note behind that the daily hint later reads. +func TestSelfUpdateCheck(t *testing.T) { + cacheInTemp(t) + srv := fakeGitea(t, `{"tag_name":"4.1.0","html_url":"https://git.micw.org/mike/mgsh/releases/tag/4.1.0", + "assets":[{"name":"mgsh-darwin-arm64","size":9,"browser_download_url":"x"}]}`) + + var out strings.Builder + if err := testUpdater(srv, "4.0.64").check(&out); err != nil { + t.Fatalf("check: %v", err) + } + if !strings.Contains(out.String(), "4.1.0 is available") { + t.Errorf("check output = %q, want the new version offered", out.String()) + } + + out.Reset() + if err := testUpdater(srv, "4.1.0").check(&out); err != nil { + t.Fatalf("check: %v", err) + } + if !strings.Contains(out.String(), "up to date") { + t.Errorf("check output = %q, want 'up to date'", out.String()) + } + + // the same version must not be offered as an update to itself + out.Reset() + if err := testUpdater(srv, "4.2.0").check(&out); err != nil { + t.Fatalf("check: %v", err) + } + if strings.Contains(out.String(), "available") { + t.Errorf("a newer running version was offered an update: %q", out.String()) + } + + cache, err := os.UserCacheDir() + if err != nil { + t.Fatal(err) + } + note := filepath.Join(cache, "mgsh", "update.json") + b, err := os.ReadFile(note) + if err != nil { + t.Fatalf("no note written to %s: %v", note, err) + } + if !strings.Contains(string(b), `"latest":"4.1.0"`) { + t.Errorf("note = %s, want the looked-up version in it", b) + } +} + +// TestSelfUpdateNoReleases: an empty repository must produce a plain message, +// not a nil release that gets compared against the running version. +func TestSelfUpdateNoReleases(t *testing.T) { + cacheInTemp(t) + srv := fakeGitea(t, `{}`) + + err := testUpdater(srv, "4.0.64").check(&strings.Builder{}) + if err == nil || !strings.Contains(err.Error(), "no releases") { + t.Errorf("check against a release-less repo = %v, want a 'no releases' error", err) + } +} + +// TestSelfUpdateWithoutMatchingAsset: a release built for other platforms must +// not be installed, and the message has to name what was looked for — that is +// what tells you the release is incomplete rather than the machine unsupported. +func TestSelfUpdateWithoutMatchingAsset(t *testing.T) { + cacheInTemp(t) + srv := fakeGitea(t, `{"tag_name":"4.1.0","assets":[{"name":"mgsh-plan9-mips","size":1,"browser_download_url":"x"}]}`) + + err := testUpdater(srv, "4.0.64").install(&strings.Builder{}) + if err == nil { + t.Fatal("install accepted a release without a binary for this platform") + } + if !strings.Contains(err.Error(), "mgsh-") || !strings.Contains(err.Error(), "mgsh-plan9-mips") { + t.Errorf("error = %v, want the wanted and the available asset names", err) + } +} + +// TestUpdateDailyStaysQuiet: the automatic look is for the person sitting +// there. Under MGSH_NO_UPDATE_CHECK, and with `every` at zero, it says nothing +// and starts nothing. +func TestUpdateDailyStaysQuiet(t *testing.T) { + cacheInTemp(t) + u := selfUpdater{asset: "mgsh", current: "4.0.0", every: 0, quietEnv: "MGSH_NO_UPDATE_CHECK"} + if hint := u.daily(); hint != "" { + t.Errorf("daily with every=0 = %q, want silence", hint) + } + + t.Setenv("MGSH_NO_UPDATE_CHECK", "1") + u.every = 1 + if hint := u.daily(); hint != "" { + t.Errorf("daily under MGSH_NO_UPDATE_CHECK = %q, want silence", hint) + } +} diff --git a/version.txt b/version.txt index 26089d1..6fb65f0 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.0.64 +4.0.65