[mike@mwxm4]
This commit is contained in:
@@ -11,6 +11,7 @@ directory. Go port of the original Perl `mgsh` (`mgsh.perl`).
|
|||||||
- [Public mirror (`pushremote`)](#public-mirror-pushremote) ·
|
- [Public mirror (`pushremote`)](#public-mirror-pushremote) ·
|
||||||
[Deleting a mirror (`deleteremote`)](#deleting-a-mirror-deleteremote) ·
|
[Deleting a mirror (`deleteremote`)](#deleting-a-mirror-deleteremote) ·
|
||||||
[Releases](#releases)
|
[Releases](#releases)
|
||||||
|
- [Updating itself](#updating-itself)
|
||||||
- [Configuration](#configuration) · [Settings reference](#settings-reference) ·
|
- [Configuration](#configuration) · [Settings reference](#settings-reference) ·
|
||||||
[Per-project configuration](#per-project-configuration)
|
[Per-project configuration](#per-project-configuration)
|
||||||
- [Git server layout](#git-server-layout)
|
- [Git server layout](#git-server-layout)
|
||||||
@@ -148,6 +149,7 @@ Run `help` for the full list. Highlights:
|
|||||||
| `unalias <name>` | remove a command alias |
|
| `unalias <name>` | remove a command alias |
|
||||||
| `config [-k]` | show the effective configuration and its sources |
|
| `config [-k]` | show the effective configuration and its sources |
|
||||||
| `rescan` | reload the config, refresh the cached repo list |
|
| `rescan` | reload the config, refresh the cached repo list |
|
||||||
|
| `update [-c]` | update mgsh to the newest release (`-c`: only look) |
|
||||||
| `!<command>` | run `<command>` in the shell |
|
| `!<command>` | run `<command>` in the shell |
|
||||||
|
|
||||||
Commands that touch a repository (`push`, `pull`, `log`, `diff`, `tag`, `dist`,
|
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
|
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.
|
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
|
## Configuration
|
||||||
|
|
||||||
mgsh has **no built-in defaults**. Settings are resolved in three steps, each
|
mgsh has **no built-in defaults**. Settings are resolved in three steps, each
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ var builtinCmds = map[string]bool{
|
|||||||
"pushremote": true, "deleteremote": true, "overview": true, "archive": true, "init": true,
|
"pushremote": true, "deleteremote": true, "overview": true, "archive": true, "init": true,
|
||||||
"login": true, "cd": true, "checkout": true, "clone": true, "cloneall": true,
|
"login": true, "cd": true, "checkout": true, "clone": true, "cloneall": true,
|
||||||
"count": true, "tag": true, "alias": 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] }
|
func isBuiltin(name string) bool { return builtinCmds[name] }
|
||||||
|
|||||||
+10
@@ -196,6 +196,15 @@ func runCommandDepth(line string, depth int) bool {
|
|||||||
}
|
}
|
||||||
showConfig()
|
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
|
case "rescan": // reload the configuration and the cached server repo list
|
||||||
reloadConfig()
|
reloadConfig()
|
||||||
rescanServer()
|
rescanServer()
|
||||||
@@ -802,6 +811,7 @@ var helpItems = []struct{ cmd, desc string }{
|
|||||||
{"unalias <name>", "remove an alias"},
|
{"unalias <name>", "remove an alias"},
|
||||||
{"config [-k]", "show effective configuration (-k: list all setting names)"},
|
{"config [-k]", "show effective configuration (-k: list all setting names)"},
|
||||||
{"rescan", "reload config and refresh cached server repository list"},
|
{"rescan", "reload config and refresh cached server repository list"},
|
||||||
|
{"update [-c]", "update mgsh to the newest release (-c: only look)"},
|
||||||
{"!<command>", "run <command> in the shell"},
|
{"!<command>", "run <command> in the shell"},
|
||||||
{"quit", "exit mgsh"},
|
{"quit", "exit mgsh"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ func builtinCompleter() *readline.PrefixCompleter {
|
|||||||
readline.PcItem("unalias", readline.PcItemDynamic(dynAliasNames)),
|
readline.PcItem("unalias", readline.PcItemDynamic(dynAliasNames)),
|
||||||
readline.PcItem("config", readline.PcItem("-k")),
|
readline.PcItem("config", readline.PcItem("-k")),
|
||||||
readline.PcItem("rescan"),
|
readline.PcItem("rescan"),
|
||||||
|
readline.PcItem("update", readline.PcItem("-c")),
|
||||||
readline.PcItem("help"),
|
readline.PcItem("help"),
|
||||||
readline.PcItem("quit"),
|
readline.PcItem("quit"),
|
||||||
readline.PcItem("exit"),
|
readline.PcItem("exit"),
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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()
|
setup()
|
||||||
useColor = readline.IsTerminal(int(os.Stdout.Fd()))
|
useColor = readline.IsTerminal(int(os.Stdout.Fd()))
|
||||||
|
|
||||||
@@ -68,12 +75,52 @@ func main() {
|
|||||||
}
|
}
|
||||||
updateDirState()
|
updateDirState()
|
||||||
runCommand(cmdline)
|
runCommand(cmdline)
|
||||||
|
updateNote() // after the output: a footer, not a headline
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateNote() // before the prompt: a session starts here, not when it ends
|
||||||
runInteractive()
|
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.
|
// runInteractive drives the colored, history- and completion-enabled REPL.
|
||||||
func runInteractive() {
|
func runInteractive() {
|
||||||
home, _ := os.UserHomeDir()
|
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,
|
"push": 1, "pushremote": 1, "deleteremote": 1, "list": 1, "tag": 1, "archive": 1, "show": 1,
|
||||||
"pull": 1, "fetch": 1, "status": 1, "diff": 1, "overview": 1,
|
"pull": 1, "fetch": 1, "status": 1, "diff": 1, "overview": 1,
|
||||||
"config": 1, "count": 1, "login": 1, "cloneall": 1, "release": 1,
|
"config": 1, "count": 1, "login": 1, "cloneall": 1, "release": 1,
|
||||||
|
"update": 1,
|
||||||
}
|
}
|
||||||
if c, ok := cls[a0]; ok {
|
if c, ok := cls[a0]; ok {
|
||||||
return c, strings.Join(os.Args[1:], " "), false
|
return c, strings.Join(os.Args[1:], " "), false
|
||||||
|
|||||||
+484
@@ -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
|
||||||
|
// "<name>-<goos>-<goarch>" each — that is, exactly the files from ./bin. Under
|
||||||
|
// /api/v1/repos/<owner>/<repo>/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, "-<goos>-<goarch>" 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.0.64
|
4.0.65
|
||||||
|
|||||||
Reference in New Issue
Block a user