Skip to content

Commit 926f471

Browse files
committed
INF-1307 add self-upgrade with prompt-driven skip
Every twoctl invocation runs a once-per-day check against the GitHub releases API. If a newer version is available and the user has not skipped it, an interactive prompt offers: [y] upgrade now -> downloads + replaces binary in place [n] not now -> default, no state change [s] skip this -> persists to state, won't ask again until the next release `twoctl upgrade` runs the check + install on demand. `twoctl upgrade --reset-skips` is the escape hatch. `twoctl upgrade --disable-autocheck` turns the prompt off entirely. `--no-upgrade-check` is a per-run override for scripts. State lives in ~/.config/twoctl/state.json. Network failures during the check are silent so they never block the actual command. Non-interactive shells get a one-liner stderr nudge instead of a prompt.
1 parent 4d76e69 commit 926f471

5 files changed

Lines changed: 448 additions & 0 deletions

File tree

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ require (
1313
)
1414

1515
require (
16+
aead.dev/minisign v0.2.0 // indirect
1617
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
1718
github.com/danieljoos/wincred v1.2.3 // indirect
1819
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
@@ -25,6 +26,7 @@ require (
2526
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2627
github.com/josharian/intern v1.0.0 // indirect
2728
github.com/mailru/easyjson v0.9.1 // indirect
29+
github.com/minio/selfupdate v0.6.0 // indirect
2830
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
2931
github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 // indirect
3032
github.com/oasdiff/yaml v0.1.0 // indirect
@@ -37,6 +39,7 @@ require (
3739
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
3840
github.com/woodsbury/decimal128 v1.4.0 // indirect
3941
go.yaml.in/yaml/v3 v3.0.4 // indirect
42+
golang.org/x/crypto v0.46.0 // indirect
4043
golang.org/x/mod v0.33.0 // indirect
4144
golang.org/x/sync v0.19.0 // indirect
4245
golang.org/x/sys v0.44.0 // indirect

go.sum

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
2+
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
13
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
24
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
35
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
@@ -68,6 +70,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
6870
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
6971
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
7072
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
73+
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
74+
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
7175
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
7276
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
7377
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
@@ -139,6 +143,10 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
139143
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
140144
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
141145
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
146+
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
147+
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
148+
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
149+
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
142150
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
143151
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
144152
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
@@ -148,6 +156,7 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
148156
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
149157
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
150158
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
159+
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
151160
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
152161
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
153162
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
@@ -161,17 +170,20 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
161170
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
162171
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
163172
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
173+
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
164174
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
165175
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
166176
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
167177
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
168178
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
169179
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
180+
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
170181
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
171182
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
172183
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
173184
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
174185
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
186+
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
175187
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
176188
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
177189
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=

internal/updater/archive.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package updater
2+
3+
import (
4+
"archive/tar"
5+
"archive/zip"
6+
"bytes"
7+
"compress/gzip"
8+
"errors"
9+
"io"
10+
"strings"
11+
)
12+
13+
// extractFromTarGz returns a reader positioned at the `twoctl` binary inside
14+
// a goreleaser-produced tar.gz archive.
15+
func extractFromTarGz(r io.Reader) (io.Reader, error) {
16+
gz, err := gzip.NewReader(r)
17+
if err != nil {
18+
return nil, err
19+
}
20+
tr := tar.NewReader(gz)
21+
for {
22+
hdr, err := tr.Next()
23+
if errors.Is(err, io.EOF) {
24+
return nil, errors.New("twoctl binary not found in archive")
25+
}
26+
if err != nil {
27+
return nil, err
28+
}
29+
if isTwoctlBinary(hdr.Name) {
30+
// Copy into memory so the caller can read after the
31+
// archive reader goes out of scope.
32+
buf, err := io.ReadAll(tr)
33+
if err != nil {
34+
return nil, err
35+
}
36+
return bytes.NewReader(buf), nil
37+
}
38+
}
39+
}
40+
41+
func extractFromZip(buf []byte) (io.Reader, error) {
42+
zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
43+
if err != nil {
44+
return nil, err
45+
}
46+
for _, f := range zr.File {
47+
if !isTwoctlBinary(f.Name) {
48+
continue
49+
}
50+
rc, err := f.Open()
51+
if err != nil {
52+
return nil, err
53+
}
54+
defer rc.Close()
55+
out, err := io.ReadAll(rc)
56+
if err != nil {
57+
return nil, err
58+
}
59+
return bytes.NewReader(out), nil
60+
}
61+
return nil, errors.New("twoctl binary not found in archive")
62+
}
63+
64+
func isTwoctlBinary(name string) bool {
65+
base := name
66+
if i := strings.LastIndexAny(name, "/\\"); i >= 0 {
67+
base = name[i+1:]
68+
}
69+
return base == "twoctl" || base == "twoctl.exe"
70+
}

internal/updater/state.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Package updater handles self-upgrading the twoctl binary from GitHub
2+
// releases. State (last check time, skipped versions) is persisted to
3+
// ~/.config/twoctl/state.json so the user's "skip this version" choice
4+
// survives across runs.
5+
package updater
6+
7+
import (
8+
"encoding/json"
9+
"errors"
10+
"fmt"
11+
"io/fs"
12+
"os"
13+
"path/filepath"
14+
"time"
15+
)
16+
17+
// State is the on-disk preference + check-cache file.
18+
type State struct {
19+
LastCheckAt time.Time `json:"last_check_at,omitempty"`
20+
LatestKnownVersion string `json:"latest_known_version,omitempty"`
21+
SkippedVersions []string `json:"skipped_versions,omitempty"`
22+
// AutoCheck disables the periodic check entirely when false. Defaults
23+
// to true on first run; the user can flip it via `twoctl upgrade
24+
// --disable-autocheck`.
25+
AutoCheck *bool `json:"auto_check,omitempty"`
26+
}
27+
28+
// IsSkipped reports whether version v has been explicitly skipped by the user.
29+
func (s *State) IsSkipped(v string) bool {
30+
for _, sv := range s.SkippedVersions {
31+
if sv == v {
32+
return true
33+
}
34+
}
35+
return false
36+
}
37+
38+
// AddSkip adds v to the skip list if not already present.
39+
func (s *State) AddSkip(v string) {
40+
if s.IsSkipped(v) {
41+
return
42+
}
43+
s.SkippedVersions = append(s.SkippedVersions, v)
44+
}
45+
46+
// AutoCheckEnabled returns whether autocheck should run. Defaults to true
47+
// when the field has never been set.
48+
func (s *State) AutoCheckEnabled() bool {
49+
if s.AutoCheck == nil {
50+
return true
51+
}
52+
return *s.AutoCheck
53+
}
54+
55+
func statePath() (string, error) {
56+
dir, err := os.UserConfigDir()
57+
if err != nil {
58+
return "", err
59+
}
60+
return filepath.Join(dir, "twoctl", "state.json"), nil
61+
}
62+
63+
// LoadState reads the state file. A missing file returns an empty State,
64+
// not an error.
65+
func LoadState() (*State, error) {
66+
p, err := statePath()
67+
if err != nil {
68+
return nil, err
69+
}
70+
b, err := os.ReadFile(p)
71+
if errors.Is(err, fs.ErrNotExist) {
72+
return &State{}, nil
73+
}
74+
if err != nil {
75+
return nil, fmt.Errorf("reading %s: %w", p, err)
76+
}
77+
var s State
78+
if err := json.Unmarshal(b, &s); err != nil {
79+
// A corrupt state file is recoverable - start fresh rather than
80+
// blocking every command.
81+
return &State{}, nil
82+
}
83+
return &s, nil
84+
}
85+
86+
// SaveState atomically writes the state file.
87+
func SaveState(s *State) error {
88+
p, err := statePath()
89+
if err != nil {
90+
return err
91+
}
92+
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
93+
return err
94+
}
95+
tmp, err := os.CreateTemp(filepath.Dir(p), "state-*.json")
96+
if err != nil {
97+
return err
98+
}
99+
enc := json.NewEncoder(tmp)
100+
enc.SetIndent("", " ")
101+
if err := enc.Encode(s); err != nil {
102+
tmp.Close()
103+
os.Remove(tmp.Name())
104+
return err
105+
}
106+
if err := tmp.Close(); err != nil {
107+
return err
108+
}
109+
return os.Rename(tmp.Name(), p)
110+
}

0 commit comments

Comments
 (0)