2 Commits
Author SHA1 Message Date
mike d26a008763 [mike@maginot] 2026-08-10 20:13:52 +02:00
mike 659ab03652 [mike@maginot] 2026-08-10 19:17:03 +02:00
4 changed files with 603 additions and 1 deletions
+92
View File
@@ -50,6 +50,95 @@ 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`.
### Einmal am Tag von selbst
Ohne Zutun sieht `dx` einmal am Tag nach und sagt danach am Ende der Ausgabe auf
stderr Bescheid:
```
dx 2.1.9 is available, run 'dx --update'
```
Der Lauf im Vordergrund fasst dafür nie das Netz an. Er liest nur einen
Merkzettel — `~/Library/Caches/dx/update.json`, unter Linux
`~/.cache/dx/update.json` — und wenn der älter als einen Tag ist, startet er
nebenbei `dx --update-refresh`: denselben Läufer noch einmal, abgekoppelt, ohne
Ausgabe, nur zum Fragen. Auf dessen Antwort wartet niemand, sie steht beim
nächsten Aufruf im Merkzettel. `dx` bleibt damit genau so schnell wie vorher,
auch wenn der Server gerade schweigt.
Der Zeitstempel wandert weiter, *bevor* gefragt wird. Zwei gleichzeitige Läufe
starten deshalb nur eine Abfrage, und ein Server, der nicht antwortet, wird
morgen wieder gefragt und nicht bei jedem Aufruf. Lässt sich der Merkzettel
nicht schreiben, unterbleibt die Frage ganz — sonst hinge an einem
schreibgeschützten Cache-Verzeichnis ein Prozess je Aufruf.
Gefragt und gesagt wird nur, wenn stderr am Terminal hängt. In einer Pipe, in
einem Skript und unter cron ist Ruhe, und `DX_NO_UPDATE_CHECK=1` schaltet es
ganz ab.
Das kostet Platz: `net/http` samt TLS macht aus einem 1,8-MB-Binary eins von
6,2 MB. Für ein Werkzeug, das ohnehin auf der Platte liegt, ist das der Preis
dafür, sich selbst erneuern zu können — wer ihn nicht zahlen will, lässt
`selfupdate.go` weg.
## 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"},
every: 24 * time.Hour,
quietEnv: "FOO_NO_UPDATE_CHECK",
}
```
Dazu drei Zeilen im Optionenteil — `--update`, `--check-update` und das
versteckte `updateRefreshFlag` — und eine am Ende des Programms:
```go
if hint := selfUpdate.daily(); hint != "" {
fmt.Fprintln(os.Stderr, hint)
}
```
`daily` liefert nur den Text; wie er aussieht, entscheidet das Programm. 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; `every: 0` schaltet die Nachschau von selbst
ab und lässt nur die beiden Optionen übrig. Erwartet wird eine Gitea-Instanz
(getestet mit 1.27); GitHub kennt dieselbe Route unter anderen Feldnamen und
wird nicht bedient.
## Optionen
| | | |
@@ -62,6 +151,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 +254,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 |
+25
View File
@@ -383,6 +383,11 @@ 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
dx looks for a new release once a day, in the background, and says so on
stderr. DX_NO_UPDATE_CHECK=1 turns that off.
`, version)
}
@@ -412,6 +417,21 @@ 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 == updateRefreshFlag: // der Hintergrundlauf, nicht in der Hilfe
selfUpdate.refresh()
return
case a == "--number":
o.byCount = true
case a == "--dirs":
@@ -493,6 +513,11 @@ func main() {
if res.unreadbl > 0 {
fmt.Fprintln(os.Stderr, cDark.Sprintf("dx: %d unreadable entries skipped", res.unreadbl))
}
// Kostet nichts: der Hinweis stammt aus dem Merkzettel, gefragt wird
// höchstens einmal am Tag und dann im Hintergrund.
if hint := selfUpdate.daily(); hint != "" {
fmt.Fprintln(os.Stderr, cDark.Sprint(hint))
}
}
func mustNum(s string) int {
+485
View File
@@ -0,0 +1,485 @@
// 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 "<name>-<goos>-<goarch>" — also genau die Dateien aus
// ./bin. Gitea liefert unter /api/v1/repos/<owner>/<repo>/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"},
every: 24 * time.Hour,
quietEnv: "DX_NO_UPDATE_CHECK",
}
type selfUpdater struct {
repo string // Repo-URL wie im Browser: https://host/owner/repo
asset string // Basisname der Assets, "-<goos>-<goarch>" kommt dazu
current string // laufende Version
verify []string // Probelauf des Downloads; leer lässt ihn ausfallen
every time.Duration // Abstand der Nachschau von selbst; 0 schaltet sie ab
quietEnv string // diese Umgebungsvariable gesetzt: auch dann Ruhe
}
// updateRefreshFlag ist die Option, mit der das Programm sich selbst im
// Hintergrund aufruft. Sie steht bewusst nicht in der Hilfe.
const updateRefreshFlag = "--update-refresh"
// ------------------------------------------------------- Nachschau von selbst
// daily ist der Anschluss für den gewöhnlichen Programmlauf. Sie kostet nichts:
// im Vordergrund wird nie das Netz angefasst. Zurück kommt die Zeile, die auf
// eine neue Version hinweist — oder "", wenn es nichts zu sagen gibt; wie sie
// aussieht, entscheidet der Aufrufer. Ist der Merkzettel älter als `every`,
// stößt sie nebenbei einen Hintergrundlauf an, dessen Antwort der nächste
// Aufruf vorfindet.
func (u selfUpdater) daily() string {
if u.every <= 0 || os.Getenv(u.quietEnv) != "" || !updateOnTerminal() {
return ""
}
st := u.loadState() // keine Datei: Nullwert, also sofort fällig
if time.Since(st.Checked) >= u.every {
// Der Zeitstempel wandert weiter, bevor gefragt wird, nicht danach:
// sonst starten zwei gleichzeitige Läufe zwei Abfragen, und ein Server,
// der gerade nicht mag, bekäme bei jedem Aufruf eine neue. Bleibt der
// Merkzettel nicht liegen, wird auch nicht gefragt — sonst hinge an
// einem unbeschreibbaren Cache-Verzeichnis ein Prozess je Aufruf.
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 ist der Hintergrundlauf: fragen, notieren, still bleiben. Das
// Notieren erledigt latest, misslingt die Abfrage, bleibt der alte Stand.
func (u selfUpdater) refresh() {
_, _ = u.latest()
}
// spawnRefresh ruft dieses Programm noch einmal auf, nur zum Fragen, und wartet
// nicht. Ohne Wait wird das Kind beim Ende dieses Prozesses von init
// übernommen — es lebt also länger als der Aufruf, und dessen Ausgabe bleibt
// davon unberührt.
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 // alles nach /dev/null
if cmd.Start() == nil {
cmd.Process.Release()
}
}
// Der Hinweis gilt dem Menschen davor. Läuft das Programm in einer Pipe, in
// einem Skript oder unter cron, wird weder gefragt noch etwas gesagt.
func updateOnTerminal() bool {
st, err := os.Stderr.Stat()
return err == nil && st.Mode()&os.ModeCharDevice != 0
}
// ------------------------------------------------------------- Merkzettel
// updateState ist das, was zwischen zwei Aufrufen übrig bleibt: wann zuletzt
// gefragt wurde und was dabei herauskam.
type updateState struct {
Checked time.Time `json:"checked"`
Latest string `json:"latest"`
}
// Der Merkzettel liegt im Cache-Verzeichnis, nicht in der Konfiguration: geht
// er verloren, wird eben einmal zu früh gefragt.
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) // eine kaputte Datei zählt wie keine
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
}
// Über eine Nebendatei, damit ein gleichzeitiger Lauf nie ein halbes JSON
// vorfindet.
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
}
// ------------------------------------------------------------------ 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(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 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
}
// Die Frage ist klein; hängt sie, hängt sie nicht lange. Das großzügige
// Zeitlimit von updateClient gilt dem 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)
}
// Jede geglückte Frage füllt den Merkzettel — egal ob sie von --update,
// --check-update oder dem Hintergrundlauf kam.
u.saveState(updateState{Checked: time.Now(), Latest: rel.TagName})
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(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
}
// ------------------------------------------------------------------ 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)
}
}
+1 -1
View File
@@ -1 +1 @@
2.1.4
2.1.10