Skip to content

Commit bc4f97b

Browse files
scalvertclaude
andcommitted
feat: add glean update command for self-updating the CLI
- Detects Homebrew installs and delegates to `brew upgrade` - Downloads the correct platform archive from GitHub Releases - Verifies SHA-256 checksum against checksums.txt before applying - Atomically replaces the running binary via minio/selfupdate - Suppresses redundant background update notice during `glean update` - Dev builds are rejected with a clear error message Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
1 parent 94e81c9 commit bc4f97b

File tree

6 files changed

+274
-1
lines changed

6 files changed

+274
-1
lines changed

cmd/root.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ func NewCmdRoot() *cobra.Command {
5252
_ = verbosity // reserved for future debug logging
5353
},
5454
PersistentPostRun: func(cmd *cobra.Command, args []string) {
55+
// Skip update notice when the user is already running `glean update`.
56+
if cmd.Name() == "update" {
57+
return
58+
}
5559
noticeCh := update.CheckAsync(cliVersion)
5660
if notice := <-noticeCh; notice != "" {
5761
fmt.Fprintf(os.Stderr, "\n%s\n", notice)
@@ -156,6 +160,8 @@ func NewCmdRoot() *cobra.Command {
156160
genSkills.Hidden = true
157161
cmd.AddCommand(genSkills)
158162

163+
cmd.AddCommand(NewCmdUpdate())
164+
159165
// Propagate settings to all subcommands
160166
for _, subCmd := range cmd.Commands() {
161167
subCmd.SilenceUsage = true

cmd/update.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/gleanwork/glean-cli/internal/update"
7+
"github.com/spf13/cobra"
8+
)
9+
10+
func NewCmdUpdate() *cobra.Command {
11+
return &cobra.Command{
12+
Use: "update",
13+
Short: "Update the glean CLI to the latest version",
14+
Long: `Check for a newer release of the glean CLI and install it.
15+
16+
If glean was installed via Homebrew, this runs:
17+
brew upgrade gleanwork/tap/glean-cli
18+
19+
Otherwise the latest binary is downloaded from GitHub Releases,
20+
its SHA-256 checksum is verified, and the running binary is replaced.`,
21+
RunE: func(cmd *cobra.Command, args []string) error {
22+
// Read cliVersion at call time, not construction time, so the
23+
// ldflags-injected version (set via SetVersion in main) is used.
24+
if err := update.Upgrade(cliVersion); err != nil {
25+
return fmt.Errorf("update failed: %w", err)
26+
}
27+
return nil
28+
},
29+
}
30+
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ require (
2828
)
2929

3030
require (
31+
aead.dev/minisign v0.2.0 // indirect
3132
al.essio.dev/pkg/shellescape v1.5.1 // indirect
3233
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
3334
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -56,6 +57,7 @@ require (
5657
github.com/mattn/go-localereader v0.0.1 // indirect
5758
github.com/mattn/go-runewidth v0.0.19 // indirect
5859
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
60+
github.com/minio/selfupdate v0.6.0 // indirect
5961
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
6062
github.com/muesli/cancelreader v0.2.2 // indirect
6163
github.com/muesli/reflow v0.3.0 // indirect
@@ -66,6 +68,7 @@ require (
6668
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
6769
github.com/yuin/goldmark v1.7.13 // indirect
6870
github.com/yuin/goldmark-emoji v1.0.6 // indirect
71+
golang.org/x/crypto v0.36.0 // indirect
6972
golang.org/x/net v0.38.0 // indirect
7073
golang.org/x/sync v0.18.0 // indirect
7174
golang.org/x/sys v0.37.0 // indirect

go.sum

Lines changed: 22 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
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
24
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
35
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
@@ -103,6 +105,8 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF
103105
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
104106
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
105107
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
108+
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
109+
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
106110
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
107111
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
108112
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -144,24 +148,42 @@ github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJ
144148
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
145149
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
146150
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
151+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
152+
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
153+
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
154+
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
155+
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
147156
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
148157
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
158+
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
159+
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
149160
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
150161
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
151162
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
152163
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
153164
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
154165
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
166+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
167+
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
168+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
169+
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
170+
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
171+
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
155172
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
156173
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
157174
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
158175
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
159176
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
160177
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
178+
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
179+
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
161180
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
162181
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
182+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
183+
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
163184
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
164185
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
186+
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
165187
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
166188
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
167189
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

internal/update/check.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const (
1919
cacheFile = ".glean/update-check.json"
2020
checkInterval = 24 * time.Hour
2121
releaseAPIURL = "https://api.github.com/repos/gleanwork/glean-cli/releases/latest"
22+
devVersion = "dev"
2223
)
2324

2425
type cacheEntry struct {
@@ -44,7 +45,7 @@ func CheckAsync(currentVersion string) <-chan string {
4445

4546
func check(currentVersion string) string {
4647
// Skip for dev builds.
47-
if currentVersion == "dev" || currentVersion == "" {
48+
if currentVersion == devVersion || currentVersion == "" {
4849
return ""
4950
}
5051

internal/update/upgrade.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package update
2+
3+
import (
4+
"archive/tar"
5+
"archive/zip"
6+
"bytes"
7+
"compress/gzip"
8+
"crypto/sha256"
9+
"encoding/hex"
10+
"fmt"
11+
"io"
12+
"net/http"
13+
"os"
14+
"os/exec"
15+
"runtime"
16+
"strings"
17+
"time"
18+
19+
"github.com/minio/selfupdate"
20+
)
21+
22+
const (
23+
releaseDownloadURL = "https://github.com/gleanwork/glean-cli/releases/download"
24+
checksumFile = "checksums.txt"
25+
binaryName = "glean"
26+
)
27+
28+
// Upgrade checks for a newer release and installs it.
29+
// If the binary was installed via Homebrew it delegates to `brew upgrade`.
30+
// Otherwise it downloads the appropriate archive from GitHub Releases,
31+
// verifies its SHA-256 checksum, and atomically replaces the running binary.
32+
func Upgrade(currentVersion string) error {
33+
if currentVersion == devVersion || currentVersion == "" {
34+
return fmt.Errorf("cannot update a dev build — build from source instead")
35+
}
36+
37+
// Homebrew-managed install: let brew handle the upgrade.
38+
if isBrewInstall() {
39+
return brewUpgrade()
40+
}
41+
42+
fmt.Fprintln(os.Stderr, "Checking for updates...")
43+
44+
latest, err := fetchLatestTag()
45+
if err != nil {
46+
return fmt.Errorf("could not fetch latest release: %w", err)
47+
}
48+
49+
if !isNewer(latest, currentVersion) {
50+
fmt.Printf("Already up to date (%s)\n", currentVersion)
51+
return nil
52+
}
53+
54+
fmt.Fprintf(os.Stderr, "Updating %s → %s\n", currentVersion, latest)
55+
56+
assetName := assetFilename()
57+
assetURL := fmt.Sprintf("%s/%s/%s", releaseDownloadURL, latest, assetName)
58+
checksumURL := fmt.Sprintf("%s/%s/%s", releaseDownloadURL, latest, checksumFile)
59+
60+
archive, err := download(assetURL)
61+
if err != nil {
62+
return fmt.Errorf("download failed: %w", err)
63+
}
64+
65+
if err := verifyChecksum(archive, assetName, checksumURL); err != nil {
66+
return fmt.Errorf("checksum verification failed: %w", err)
67+
}
68+
69+
binary, err := extractBinary(assetName, archive)
70+
if err != nil {
71+
return fmt.Errorf("could not extract binary: %w", err)
72+
}
73+
74+
if err := selfupdate.Apply(bytes.NewReader(binary), selfupdate.Options{}); err != nil {
75+
return fmt.Errorf("could not apply update: %w", err)
76+
}
77+
78+
fmt.Printf("Updated to %s\n", latest)
79+
return nil
80+
}
81+
82+
// isBrewInstall reports whether the running binary lives inside a Homebrew Cellar.
83+
func isBrewInstall() bool {
84+
exe, err := os.Executable()
85+
if err != nil {
86+
return false
87+
}
88+
return strings.Contains(exe, "/Cellar/") || strings.Contains(exe, "/homebrew/")
89+
}
90+
91+
// brewUpgrade runs `brew upgrade gleanwork/tap/glean-cli`.
92+
func brewUpgrade() error {
93+
fmt.Fprintln(os.Stderr, "Detected Homebrew install — running brew upgrade...")
94+
cmd := exec.Command("brew", "upgrade", "gleanwork/tap/glean-cli")
95+
cmd.Stdout = os.Stdout
96+
cmd.Stderr = os.Stderr
97+
return cmd.Run()
98+
}
99+
100+
// assetFilename returns the expected archive name for the current platform.
101+
// Matches the GoReleaser name_template in .goreleaser.yaml:
102+
// glean-cli_{OS}_{arch}.tar.gz (e.g. glean-cli_Darwin_arm64.tar.gz)
103+
func assetFilename() string {
104+
goos := runtime.GOOS
105+
goarch := runtime.GOARCH
106+
107+
// GoReleaser uses the `title` filter: "darwin" → "Darwin"
108+
osName := strings.ToUpper(goos[:1]) + goos[1:]
109+
archName := goarch
110+
if goarch == "amd64" {
111+
archName = "x86_64"
112+
}
113+
114+
ext := "tar.gz"
115+
if goos == "windows" {
116+
ext = "zip"
117+
}
118+
119+
return fmt.Sprintf("glean-cli_%s_%s.%s", osName, archName, ext)
120+
}
121+
122+
// download fetches a URL and returns the body bytes.
123+
func download(url string) ([]byte, error) {
124+
client := &http.Client{Timeout: 120 * time.Second}
125+
resp, err := client.Get(url)
126+
if err != nil {
127+
return nil, err
128+
}
129+
defer resp.Body.Close()
130+
if resp.StatusCode != http.StatusOK {
131+
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
132+
}
133+
return io.ReadAll(resp.Body)
134+
}
135+
136+
// verifyChecksum fetches checksums.txt and confirms the archive matches.
137+
func verifyChecksum(archive []byte, assetName, checksumURL string) error {
138+
raw, err := download(checksumURL)
139+
if err != nil {
140+
return fmt.Errorf("could not fetch checksums: %w", err)
141+
}
142+
143+
want := ""
144+
for line := range strings.SplitSeq(string(raw), "\n") {
145+
fields := strings.Fields(line)
146+
if len(fields) == 2 && fields[1] == assetName {
147+
want = fields[0]
148+
break
149+
}
150+
}
151+
if want == "" {
152+
return fmt.Errorf("no checksum entry found for %s", assetName)
153+
}
154+
155+
sum := sha256.Sum256(archive)
156+
got := hex.EncodeToString(sum[:])
157+
if got != want {
158+
return fmt.Errorf("checksum mismatch: got %s want %s", got, want)
159+
}
160+
return nil
161+
}
162+
163+
// extractBinary pulls the `glean` (or `glean.exe`) binary out of the archive.
164+
func extractBinary(assetName string, data []byte) ([]byte, error) {
165+
if strings.HasSuffix(assetName, ".zip") {
166+
return extractFromZip(data)
167+
}
168+
return extractFromTarGz(data)
169+
}
170+
171+
func extractFromTarGz(data []byte) ([]byte, error) {
172+
gz, err := gzip.NewReader(bytes.NewReader(data))
173+
if err != nil {
174+
return nil, err
175+
}
176+
defer gz.Close()
177+
178+
tr := tar.NewReader(gz)
179+
for {
180+
hdr, err := tr.Next()
181+
if err == io.EOF {
182+
break
183+
}
184+
if err != nil {
185+
return nil, err
186+
}
187+
if hdr.Name == binaryName || strings.HasSuffix(hdr.Name, "/"+binaryName) {
188+
return io.ReadAll(tr)
189+
}
190+
}
191+
return nil, fmt.Errorf("glean binary not found in archive")
192+
}
193+
194+
func extractFromZip(data []byte) ([]byte, error) {
195+
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
196+
if err != nil {
197+
return nil, err
198+
}
199+
for _, f := range r.File {
200+
if f.Name == binaryName+".exe" || strings.HasSuffix(f.Name, "/"+binaryName+".exe") ||
201+
f.Name == binaryName || strings.HasSuffix(f.Name, "/"+binaryName) {
202+
rc, err := f.Open()
203+
if err != nil {
204+
return nil, err
205+
}
206+
defer rc.Close()
207+
return io.ReadAll(rc)
208+
}
209+
}
210+
return nil, fmt.Errorf("glean binary not found in archive")
211+
}

0 commit comments

Comments
 (0)