[mike@maginot]

This commit is contained in:
2026-08-10 20:13:52 +02:00
parent 659ab03652
commit ef6382e8ad
4 changed files with 214 additions and 22 deletions
+54 -9
View File
@@ -73,6 +73,38 @@ entweder die alte Datei oder die neue, nie eine halbe.
Liegt das Binary irgendwo, wo der eigene Benutzer nicht schreiben darf Liegt das Binary irgendwo, wo der eigene Benutzer nicht schreiben darf
(`/usr/local/bin`), sagt `dx --update` das und tut nichts — dann `sudo`. (`/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 ## Wiederverwenden
`selfupdate.go` hängt an nichts im Rest des Programms und kommt mit der `selfupdate.go` hängt an nichts im Rest des Programms und kommt mit der
@@ -81,18 +113,31 @@ oben anpassen —
```go ```go
var selfUpdate = selfUpdater{ var selfUpdate = selfUpdater{
repo: "https://git.micw.org/mike/foo", repo: "https://git.micw.org/mike/foo",
asset: "foo", asset: "foo",
current: version, current: version,
verify: []string{"--version"}, verify: []string{"--version"},
every: 24 * time.Hour,
quietEnv: "FOO_NO_UPDATE_CHECK",
} }
``` ```
— und `--update` und `--check-update` in die Optionen hängen. Alles Weitere in Dazu drei Zeilen im Optionenteil — `--update`, `--check-update` und das
der Datei heißt `selfUpdate…` oder `update…` und kollidiert deshalb nicht. versteckte `updateRefreshFlag` — und eine am Ende des Programms:
`verify` leer lassen, wenn das Programm keinen billigen Versionsaufruf hat; dann
entfällt der Probelauf. Erwartet wird eine Gitea-Instanz (getestet mit 1.27); ```go
GitHub kennt dieselbe Route unter anderen Feldnamen und wird nicht bedient. 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 ## Optionen
+11
View File
@@ -385,6 +385,9 @@ usage: dx [-n] [-d] [-r] [-t N] [path]
--version --version
--check-update look for a newer release --check-update look for a newer release
--update download and install the newest 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) `, version)
} }
@@ -426,6 +429,9 @@ func main() {
os.Exit(1) os.Exit(1)
} }
return return
case a == updateRefreshFlag: // der Hintergrundlauf, nicht in der Hilfe
selfUpdate.refresh()
return
case a == "--number": case a == "--number":
o.byCount = true o.byCount = true
case a == "--dirs": case a == "--dirs":
@@ -507,6 +513,11 @@ func main() {
if res.unreadbl > 0 { if res.unreadbl > 0 {
fmt.Fprintln(os.Stderr, cDark.Sprintf("dx: %d unreadable entries skipped", res.unreadbl)) 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 { func mustNum(s string) int {
+148 -12
View File
@@ -34,17 +34,144 @@ import (
// ------------------------------------------------------------ Konfiguration // ------------------------------------------------------------ Konfiguration
var selfUpdate = selfUpdater{ var selfUpdate = selfUpdater{
repo: "https://git.micw.org/mike/dx", repo: "https://git.micw.org/mike/dx",
asset: "dx", asset: "dx",
current: version, // aus main.go, per -ldflags gesetzt current: version, // aus main.go, per -ldflags gesetzt
verify: []string{"--version"}, verify: []string{"--version"},
every: 24 * time.Hour,
quietEnv: "DX_NO_UPDATE_CHECK",
} }
type selfUpdater struct { type selfUpdater struct {
repo string // Repo-URL wie im Browser: https://host/owner/repo repo string // Repo-URL wie im Browser: https://host/owner/repo
asset string // Basisname der Assets, "-<goos>-<goarch>" kommt dazu asset string // Basisname der Assets, "-<goos>-<goarch>" kommt dazu
current string // laufende Version current string // laufende Version
verify []string // Probelauf des Downloads; leer lässt ihn ausfallen 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 // ------------------------------------------------------------------ Ablauf
@@ -139,7 +266,7 @@ func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode) (str
} }
tmp := f.Name() tmp := f.Name()
resp, err := updateGet(a.URL) resp, err := updateGet(context.Background(), a.URL)
if err != nil { if err != nil {
f.Close() f.Close()
os.Remove(tmp) os.Remove(tmp)
@@ -225,7 +352,12 @@ func (u selfUpdater) latest() (updateRelease, error) {
if err != nil { if err != nil {
return updateRelease{}, err return updateRelease{}, err
} }
resp, err := updateGet(base + "/releases/latest") // 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 { if err != nil {
return updateRelease{}, err return updateRelease{}, err
} }
@@ -238,6 +370,10 @@ func (u selfUpdater) latest() (updateRelease, error) {
if rel.TagName == "" { if rel.TagName == "" {
return updateRelease{}, fmt.Errorf("%s has no releases", u.repo) 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 return rel, nil
} }
@@ -261,8 +397,8 @@ func (u selfUpdater) apiBase() (string, error) {
// aber nicht ewig. // aber nicht ewig.
var updateClient = &http.Client{Timeout: 5 * time.Minute} var updateClient = &http.Client{Timeout: 5 * time.Minute}
func updateGet(target string) (*http.Response, error) { func updateGet(ctx context.Context, target string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodGet, target, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+1 -1
View File
@@ -1 +1 @@
2.1.8 2.1.10