From 659ab03652e7c2dd1f3390212b1c2e628f37bf3f Mon Sep 17 00:00:00 2001 From: Michael Wesemann Date: Mon, 10 Aug 2026 19:17:03 +0200 Subject: [PATCH] [mike@maginot] --- README.md | 47 +++++++ main.go | 14 ++ selfupdate.go | 349 ++++++++++++++++++++++++++++++++++++++++++++++++++ version.txt | 2 +- 4 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 selfupdate.go diff --git a/README.md b/README.md index 83d73a6..37d465c 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,50 @@ Braucht Go ≥ 1.26 und `github.com/fatih/color`. Das Perl-Original (`dx`, 1.0.2 2016) liegt unverändert daneben und wird vom Bau nicht angefasst; es hat keine Farben und keine Balken, tut aber sonst dasselbe. +## Selbst aktualisieren + +```sh +dx --check-update # nur nachsehen +dx --update # holen und ersetzen +``` + +`dx` holt sich das neueste Release von +[git.micw.org/mike/dx](https://git.micw.org/mike/dx) — die URL steht fest im +Programm, es gibt nichts zu konfigurieren. Gebraucht wird ein Release je +Version, dessen Tag die nackte Nummer ist (`2.1.6`), mit den Dateien aus `./bin` +als Assets; gesucht wird die eine, die zu `GOOS`/`GOARCH` dieser Maschine passt. + +Ersetzt wird die laufende Datei selbst. Ist der aufgerufene `dx` ein Symlink — +etwa `~/bin/dx` auf `~/src/dx/bin/dx-darwin-arm64` —, wird das Ziel dahinter +erneuert, nicht der Link. Vor dem Tausch wird das frisch Geladene einmal mit +`--version` aufgerufen; meldet es nicht die erwartete Nummer, bleibt alles, wie +es war. Der Tausch selbst ist ein `rename` im selben Verzeichnis, also atomar: +entweder die alte Datei oder die neue, nie eine halbe. + +Liegt das Binary irgendwo, wo der eigene Benutzer nicht schreiben darf +(`/usr/local/bin`), sagt `dx --update` das und tut nichts — dann `sudo`. + +## Wiederverwenden + +`selfupdate.go` hängt an nichts im Rest des Programms und kommt mit der +Standardbibliothek aus. Für ein anderes Programm die Datei kopieren, den Block +oben anpassen — + +```go +var selfUpdate = selfUpdater{ + repo: "https://git.micw.org/mike/foo", + asset: "foo", + current: version, + verify: []string{"--version"}, +} +``` + +— und `--update` und `--check-update` in die Optionen hängen. Alles Weitere in +der Datei heißt `selfUpdate…` oder `update…` und kollidiert deshalb nicht. +`verify` leer lassen, wenn das Programm keinen billigen Versionsaufruf hat; dann +entfällt der Probelauf. Erwartet wird eine Gitea-Instanz (getestet mit 1.27); +GitHub kennt dieselbe Route unter anderen Feldnamen und wird nicht bedient. + ## Optionen | | | | @@ -62,6 +106,8 @@ Farben und keine Balken, tut aber sonst dasselbe. | | `--bar`, `--no-bar` | Balken und Prozent erzwingen bzw. abschalten | | | `--color=auto\|always\|never` | Farbe erzwingen bzw. abschalten | | | `--version` | Version | +| | `--check-update` | nachsehen, ob ein neueres Release da ist | +| | `--update` | neuestes Release holen und installieren | Kurzoptionen lassen sich bündeln (`-rt 3`, `-dn`), der Wert von `-t` darf kleben (`-t5`) oder folgen (`-t 5`). Ein `--` trennt Optionen von einem Pfad, der mit @@ -163,6 +209,7 @@ Neu sind `-r`, `-t`, die Balkenspalte, die Farben und die langen Optionen. |---|---| | `main.go` | Zählen, Sortieren, Formatieren, Optionen | | `filetypes.go` | Endung und Dateiname → Farbe | +| `selfupdate.go` | `--update`, eigenständig und zum Kopieren gedacht | | `build.sh` | Bau für alle Plattformen, zählt `version.txt` hoch | | `version.txt` | die zuletzt gebaute Version | | `dx` | das Perl-Original von 2016 | diff --git a/main.go b/main.go index 2317a85..d2c858a 100644 --- a/main.go +++ b/main.go @@ -383,6 +383,8 @@ usage: dx [-n] [-d] [-r] [-t N] [path] --bar / --no-bar share bar and percentage (default: when colorized) --color=auto|always|never colorize output (default auto) --version + --check-update look for a newer release + --update download and install the newest release `, version) } @@ -412,6 +414,18 @@ func main() { case a == "--version": fmt.Printf("dx %s\n", version) return + case a == "--update": + if err := selfUpdate.install(os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "dx: %v\n", err) + os.Exit(1) + } + return + case a == "--check-update": + if err := selfUpdate.check(os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "dx: %v\n", err) + os.Exit(1) + } + return case a == "--number": o.byCount = true case a == "--dirs": diff --git a/selfupdate.go b/selfupdate.go new file mode 100644 index 0000000..4919929 --- /dev/null +++ b/selfupdate.go @@ -0,0 +1,349 @@ +// selfupdate.go — Selbstaktualisierung aus den Releases einer Gitea-Instanz. +// +// Die Datei ist als Kopiervorlage gedacht: in ein anderes Programm übernehmen, +// den Konfigurationsblock unten anpassen, `--update` und `--check-update` in +// die Optionen hängen — fertig. Sie braucht nur die Standardbibliothek und +// bringt außer dem Block keine Namen mit, die nicht mit "selfUpdate" oder +// "update" beginnen. +// +// Vorausgesetzt wird das Ablageschema von build.sh: je Version ein Release, +// dessen Tag die nackte Nummer ist (2.1.6, ein führendes "v" ist erlaubt), und +// darin je ein Asset "--" — also genau die Dateien aus +// ./bin. Gitea liefert unter /api/v1/repos///releases/latest das +// neueste Release ohne Entwurf und ohne Vorabversion; GitHub spricht dieselbe +// Route mit anderen Feldnamen und ist deshalb nicht mitgemeint. +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +// ------------------------------------------------------------ Konfiguration + +var selfUpdate = selfUpdater{ + repo: "https://git.micw.org/mike/dx", + asset: "dx", + current: version, // aus main.go, per -ldflags gesetzt + verify: []string{"--version"}, +} + +type selfUpdater struct { + repo string // Repo-URL wie im Browser: https://host/owner/repo + asset string // Basisname der Assets, "--" kommt dazu + current string // laufende Version + verify []string // Probelauf des Downloads; leer lässt ihn ausfallen +} + +// ------------------------------------------------------------------ Ablauf + +// check sieht nur nach und fasst nichts an. +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 holt das neueste Release und ersetzt die laufende Datei damit. +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) + } + // Ein installiertes dx ist oft ein Symlink nach ./bin. Ersetzt werden soll + // die Datei dahinter, nicht der 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) // greift nur, wenn das Umbenennen unten ausfällt + + 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) { + // Die neue Datei entsteht neben der alten: dasselbe Dateisystem, also ist + // das Umbenennen am Ende ein atomarer Schritt und kein halber Kopiervorgang. + // Sie entsteht auch vor dem ersten Byte — ein fehlendes Schreibrecht soll + // auffallen, bevor ein paar Megabyte durch die Leitung sind. + dir := filepath.Dir(exe) + f, err := os.CreateTemp(dir, "."+filepath.Base(exe)+".new") + if err != nil { + var pe *os.PathError // der Pfad steht schon in der Meldung + if errors.As(err, &pe) { + err = pe.Err + } + return "", fmt.Errorf("cannot write to %s: %w", dir, err) + } + tmp := f.Name() + + resp, err := updateGet(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 ruft den frisch geladenen Läufer einmal auf. Das fängt eine +// abgeschnittene, für die falsche Plattform gebaute oder gar nicht erst +// ausführbare Datei ab, bevor sie die laufende ersetzt. +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 tauscht die laufende Datei gegen die neue. +func updateReplace(tmp, exe string) error { + if err := os.Rename(tmp, exe); err == nil { + return nil + } + // Unix überschreibt die Datei eines laufenden Programms klaglos, Windows + // nicht: dort muss die alte erst aus dem Weg. Löschen lässt sie sich + // frühestens, wenn dieser Prozess endet — das Aufräumen darf also scheitern. + 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) // zurück auf den alten Stand + 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 + } + resp, err := updateGet(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) + } + return rel, nil +} + +// apiBase macht aus https://host/owner/repo die API-Wurzel des Repos. +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 +} + +// Ein Zeitlimit für alles zusammen: die Suche kostet ein paar hundert +// Millisekunden, der Download ein paar Megabyte — beides darf hängen bleiben, +// aber nicht ewig. +var updateClient = &http.Client{Timeout: 5 * time.Minute} + +func updateGet(target string) (*http.Response, error) { + req, err := http.NewRequest(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 +} + +// ------------------------------------------------------------------ Nummern + +// updateCompare vergleicht zwei Versionen komponentenweise numerisch, damit +// 2.1.10 hinter 2.1.9 landet und nicht davor. Ein führendes "v" zählt nicht, +// fehlende Stellen gelten als 0 (2.1 == 2.1.0), und ein Suffix am Zahlenrest +// macht die Version älter, nicht neuer (2.1.6-rc1 < 2.1.6). Ergebnis wie bei +// 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 ist fertig, 2.1.6-rc1 noch nicht + return 1 + case rb == "": + return -1 + } + return strings.Compare(ra, rb) +} + +// updateSplitNum trennt "10-rc1" in 10 und "-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 ist bewusst eine eigene kleine Formatierung und nicht formSize aus +// main.go — die Datei soll für sich alleine stehen. +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/version.txt b/version.txt index 7d2ed7c..ebf14b4 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.1.4 +2.1.8