Skip to content

Commit 0dca815

Browse files
feat(update): implement update check and prompt for newer releases
1 parent aa888c6 commit 0dca815

4 files changed

Lines changed: 462 additions & 0 deletions

File tree

‎USAGE.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,28 @@ Run the same `curl` line again to upgrade; it overwrites the binary in place.
9898
To remove it, delete the one file: `sudo rm /usr/local/bin/dps`, and
9999
`rm -rf ~/.config/dps` if you saved a column set.
100100

101+
When a newer release is out, `dps` says so before the view opens:
102+
103+
```
104+
Update available: v1.0.0 → v1.1.0
105+
106+
Run this to update:
107+
curl -fsSL https://raw.githubusercontent.com/developerabdan/dps/main/install.sh | sh
108+
109+
[ enter ] continue
110+
```
111+
112+
`enter` opens the view as usual, and `ctrl-c` exits. `dps` never updates
113+
itself; the upgrade is the `curl` line, run by you.
114+
115+
The check asks the GitHub releases API at most once a day, with a two-second
116+
limit, and keeps the answer in `update.json` in your user cache directory
117+
(`~/.cache/dps`, or `$XDG_CACHE_HOME/dps`, on Linux; `~/Library/Caches/dps`
118+
on macOS). If GitHub cannot
119+
be reached, `dps` starts without the notice. The notice shows only on a
120+
terminal, never with `--plain`, `--json` or in a pipe, and never on a build
121+
from source. To turn the check off, set `DPS_NO_UPDATE_CHECK=1`.
122+
101123
## First run
102124

103125
The first `dps` on a machine with no config file opens a short setup, then

‎cmd/dps/main.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/developerabdan/dps/internal/table"
2525
"github.com/developerabdan/dps/internal/term"
2626
"github.com/developerabdan/dps/internal/ui"
27+
"github.com/developerabdan/dps/internal/update"
2728
)
2829

2930
// version is stamped at build time with -ldflags "-X main.version=...".
@@ -221,6 +222,17 @@ func run() error {
221222
return err
222223
}
223224

225+
// A newer release is announced only to a person at a terminal, and only
226+
// before a view they are about to watch. The notice waits for enter, so a
227+
// pipe, --plain or --json must never reach it.
228+
if isTTY && term.IsTTY(os.Stdin) && !*plain && !*asJSON {
229+
if latest := update.Available(ctx, version); latest != "" {
230+
if !update.Prompt(ctx, os.Stdin, os.Stdout, version, latest) {
231+
return nil
232+
}
233+
}
234+
}
235+
224236
opt := dockerapi.ListOptions{All: *all, Filters: filters}
225237
for _, c := range cols {
226238
if c.Key == "size" {

‎internal/update/update.go‎

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// Package update tells a person that a newer dps has been released. It only
2+
// tells. The upgrade is the install script, run by hand, so dps never replaces
3+
// its own binary and never downloads anything but one small JSON answer.
4+
package update
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"os"
13+
"path/filepath"
14+
"strconv"
15+
"strings"
16+
"time"
17+
)
18+
19+
// Repo is the GitHub repository the releases are published in.
20+
const Repo = "developerabdan/dps"
21+
22+
// InstallCommand upgrades dps. It is the install line again: the script
23+
// overwrites the binary in place.
24+
const InstallCommand = "curl -fsSL https://raw.githubusercontent.com/" + Repo + "/main/install.sh | sh"
25+
26+
// checkEvery is how long one answer from GitHub is trusted. Asking on every
27+
// run would put a network round trip in front of every table, and the
28+
// unauthenticated API allows 60 requests an hour per address.
29+
const checkEvery = 24 * time.Hour
30+
31+
// timeout bounds the request. An offline machine or a slow proxy costs this
32+
// much once a day, and then the answer is cached like any other.
33+
const timeout = 2 * time.Second
34+
35+
// latestURL is a variable so tests can point it at a local server.
36+
var latestURL = "https://api.github.com/repos/" + Repo + "/releases/latest"
37+
38+
// state is the cache file: when GitHub was last asked, and the newest tag it
39+
// has ever answered with.
40+
type state struct {
41+
Checked time.Time `json:"checked"`
42+
Latest string `json:"latest,omitempty"`
43+
}
44+
45+
// Available returns the tag of a release newer than current, or "" when there
46+
// is none. A development build, DPS_NO_UPDATE_CHECK=1 and any failure along the
47+
// way all return "": a version check is never a reason for dps not to start.
48+
func Available(ctx context.Context, current string) string {
49+
if os.Getenv("DPS_NO_UPDATE_CHECK") == "1" {
50+
return ""
51+
}
52+
if _, ok := parse(current); !ok {
53+
return ""
54+
}
55+
if latest := latest(ctx, cachePath(), time.Now()); Newer(latest, current) {
56+
return latest
57+
}
58+
return ""
59+
}
60+
61+
// cachePath is where the last answer is kept. It is a cache, not config: the
62+
// file can be deleted at any time and costs one request to rebuild.
63+
func cachePath() string {
64+
dir, err := os.UserCacheDir()
65+
if err != nil {
66+
return ""
67+
}
68+
return filepath.Join(dir, "dps", "update.json")
69+
}
70+
71+
// latest returns the newest release tag, from the cache while it is fresh and
72+
// from GitHub otherwise. A failed request still stamps the cache, so a machine
73+
// with no route to GitHub pays the timeout once a day and not on every run.
74+
func latest(ctx context.Context, path string, now time.Time) string {
75+
var s state
76+
if path != "" {
77+
if b, err := os.ReadFile(path); err == nil {
78+
_ = json.Unmarshal(b, &s)
79+
}
80+
}
81+
// A check stamped in the future means the clock moved back. Trusting it
82+
// would silence the check until the clock caught up again.
83+
if age := now.Sub(s.Checked); age >= 0 && age < checkEvery {
84+
return s.Latest
85+
}
86+
87+
if tag, err := fetch(ctx); err == nil {
88+
s.Latest = tag
89+
}
90+
s.Checked = now
91+
if path != "" {
92+
save(path, s)
93+
}
94+
return s.Latest
95+
}
96+
97+
func fetch(ctx context.Context) (string, error) {
98+
ctx, cancel := context.WithTimeout(ctx, timeout)
99+
defer cancel()
100+
101+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestURL, nil)
102+
if err != nil {
103+
return "", err
104+
}
105+
// GitHub refuses API requests that carry no User-Agent.
106+
req.Header.Set("User-Agent", "dps")
107+
req.Header.Set("Accept", "application/vnd.github+json")
108+
109+
resp, err := http.DefaultClient.Do(req)
110+
if err != nil {
111+
return "", err
112+
}
113+
defer resp.Body.Close()
114+
if resp.StatusCode != http.StatusOK {
115+
return "", fmt.Errorf("github answered %s", resp.Status)
116+
}
117+
118+
var body struct {
119+
TagName string `json:"tag_name"`
120+
}
121+
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
122+
return "", err
123+
}
124+
if _, ok := parse(body.TagName); !ok {
125+
return "", fmt.Errorf("github answered an unreadable tag %q", body.TagName)
126+
}
127+
return body.TagName, nil
128+
}
129+
130+
// save writes the cache through a temporary file and a rename, the same way
131+
// the config is saved. A failed write is ignored: the next run asks again.
132+
func save(path string, s state) {
133+
b, err := json.Marshal(s)
134+
if err != nil {
135+
return
136+
}
137+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
138+
return
139+
}
140+
tmp := path + ".tmp"
141+
if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil {
142+
return
143+
}
144+
_ = os.Rename(tmp, path)
145+
}
146+
147+
// Newer reports whether tag latest is a later release than tag current. A tag
148+
// that is not vMAJOR.MINOR.PATCH is never newer, so a bad answer cannot start
149+
// a notice.
150+
func Newer(latest, current string) bool {
151+
l, ok := parse(latest)
152+
if !ok {
153+
return false
154+
}
155+
c, ok := parse(current)
156+
if !ok {
157+
return false
158+
}
159+
for i := range l {
160+
if l[i] != c[i] {
161+
return l[i] > c[i]
162+
}
163+
}
164+
return false
165+
}
166+
167+
// parse reads v1.2.3 into its three numbers. A pre-release or build suffix is
168+
// ignored: releases/latest never returns a pre-release, and the release
169+
// workflow stamps plain tags.
170+
func parse(tag string) ([3]int, bool) {
171+
var out [3]int
172+
core, ok := strings.CutPrefix(tag, "v")
173+
if !ok {
174+
return out, false
175+
}
176+
if i := strings.IndexAny(core, "-+"); i >= 0 {
177+
core = core[:i]
178+
}
179+
parts := strings.Split(core, ".")
180+
if len(parts) != 3 {
181+
return out, false
182+
}
183+
for i, p := range parts {
184+
n, err := strconv.Atoi(p)
185+
if err != nil || n < 0 {
186+
return out, false
187+
}
188+
out[i] = n
189+
}
190+
return out, true
191+
}
192+
193+
const (
194+
ansiReset = "\x1b[0m"
195+
ansiBold = "\x1b[1m"
196+
ansiDim = "\x1b[2m"
197+
ansiCyan = "\x1b[36m"
198+
)
199+
200+
// Prompt shows the notice and waits for enter. It returns false when ctx ends
201+
// first — ctrl-c while the notice is up — so the caller can stop rather than
202+
// open the view the person just walked away from.
203+
//
204+
// The read runs in its own goroutine because a read on a terminal cannot be
205+
// cancelled. When ctx wins, that goroutine is left blocked, and the process
206+
// exits under it.
207+
func Prompt(ctx context.Context, in io.Reader, out io.Writer, current, latest string) bool {
208+
fmt.Fprintf(out, "\n %sUpdate available:%s %s → %s%s%s\n\n", ansiBold, ansiReset, current, ansiBold, latest, ansiReset)
209+
fmt.Fprintf(out, " Run this to update:\n")
210+
fmt.Fprintf(out, " %s%s%s\n\n", ansiCyan, InstallCommand, ansiReset)
211+
fmt.Fprintf(out, " %s[ enter ] continue%s ", ansiDim, ansiReset)
212+
213+
done := make(chan struct{})
214+
go func() {
215+
defer close(done)
216+
// One byte at a time, so nothing typed after the newline is taken
217+
// from the view that reads stdin next.
218+
b := make([]byte, 1)
219+
for {
220+
if n, err := in.Read(b); err != nil || (n == 1 && b[0] == '\n') {
221+
return
222+
}
223+
}
224+
}()
225+
226+
select {
227+
case <-done:
228+
return true
229+
case <-ctx.Done():
230+
fmt.Fprintln(out)
231+
return false
232+
}
233+
}

0 commit comments

Comments
 (0)