v0.1.90 3d7f80e

updatecmd.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"crypto/sha256"
6	"encoding/hex"
7	"errors"
8	"fmt"
9	"io"
10	"net/http"
11	"os"
12	"os/exec"
13	"path/filepath"
14	"runtime"
15	"strings"
16	"time"
17)
18
19// runUpdateCommand dispatches `zot update`. Returns (handled=true, err)
20// if rawArgs starts with "update"; otherwise (handled=false, nil) so
21// the main router falls through to the regular flag parser. Mirrors
22// the shape of runBotCommand / runExtCommand on purpose: identical
23// dispatch shape keeps the router in cli.go uniform.
24//
25// The command:
26//
27//  1. Resolves the latest release tag via the GitHub API (same code
28//     path the in-TUI update banner uses, so we never disagree about
29//     "what is latest").
30//  2. Picks the asset matching the current GOOS/GOARCH using the
31//     name template defined in .goreleaser.yaml.
32//  3. Downloads checksums.txt and the asset to a temp directory.
33//  4. Verifies the asset's sha256 against checksums.txt.
34//  5. Extracts the zot binary from the archive.
35//  6. Atomically replaces the running binary with the new one.
36//
37// Refuses to operate on dev builds (version == "0.0.0") because there
38// is no meaningful "is newer" comparison and we'd happily downgrade a
39// freshly-compiled local binary back to whatever's on GitHub.
40func runUpdateCommand(rawArgs []string, version string) (handled bool, err error) {
41	if len(rawArgs) == 0 || rawArgs[0] != "update" {
42		return false, nil
43	}
44	// Accept --help/-h for parity with the rest of the CLI.
45	for _, a := range rawArgs[1:] {
46		switch a {
47		case "-h", "--help", "help":
48			printUpdateHelp()
49			return true, nil
50		case "--check":
51			return true, runUpdateCheck(version)
52		default:
53			printUpdateHelp()
54			return true, fmt.Errorf("unknown update flag: %s", a)
55		}
56	}
57	return true, runUpdate(version)
58}
59
60func printUpdateHelp() {
61	fmt.Fprintln(os.Stderr, `zot update — replace the current zot binary with the latest release
62
63usage:
64  zot update           download and install the newest release
65  zot update --check   show what update is available; install nothing
66  zot update --help    show this help
67
68notes:
69  * The binary must be writable by the current user. On a system-wide
70    install (e.g. /usr/local/bin/zot owned by root) re-run with sudo.
71  * Dev builds (version 0.0.0) are refused — they typically come from
72    'go install' or a local 'make build' and shouldn't be silently
73    replaced with a release binary.
74  * Honours $GITHUB_TOKEN if set, so private-repo releases work.`)
75}
76
77// runUpdateCheck just prints what would happen without doing the
78// download. Useful as a sanity probe and as something the user can
79// pipe into scripts.
80func runUpdateCheck(version string) error {
81	if version == "" || version == "dev" || version == "0.0.0" {
82		fmt.Println("zot: dev build (version 0.0.0) — `zot update` is disabled")
83		return nil
84	}
85	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
86	defer cancel()
87	tag, url, err := fetchLatestRelease(ctx)
88	if err != nil {
89		return fmt.Errorf("query latest release: %w", err)
90	}
91	latest := strings.TrimPrefix(tag, "v")
92	current := versionOnly(version)
93	if !versionLess(current, latest) {
94		fmt.Printf("zot %s is up to date (latest: %s)\n", current, latest)
95		return nil
96	}
97	fmt.Printf("zot %s -> %s available\n  release: %s\n  run 'zot update' to install\n", current, latest, url)
98	return nil
99}
100
101// runUpdate is the meat of `zot update`.
102func runUpdate(version string) error {
103	if version == "" || version == "dev" || version == "0.0.0" {
104		return errors.New("dev build (version 0.0.0): `zot update` is disabled. Build a release tag or download from https://github.com/patriceckhart/zot/releases")
105	}
106	current := versionOnly(version)
107
108	ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
109	defer cancel()
110
111	fmt.Println("zot update: querying latest release...")
112	tag, releaseURL, err := fetchLatestRelease(ctx)
113	if err != nil {
114		return fmt.Errorf("query latest release: %w", err)
115	}
116	latest := strings.TrimPrefix(tag, "v")
117
118	if !versionLess(current, latest) {
119		fmt.Printf("zot %s is already up to date.\n", current)
120		return nil
121	}
122	fmt.Printf("zot update: %s -> %s\n", current, latest)
123	fmt.Printf("zot update: release page %s\n", releaseURL)
124
125	// Pick the archive matching this platform.
126	assetName, archiveFmt, err := releaseAssetName(latest)
127	if err != nil {
128		return err
129	}
130	fmt.Printf("zot update: target asset %s\n", assetName)
131
132	// We assume the standard GoReleaser layout: assets live under
133	//   https://github.com/<owner>/<repo>/releases/download/<tag>/<file>
134	base := strings.TrimSuffix(releaseURL, "/")
135	// releaseURL points at /releases/tag/<tag>; flip it to /releases/download/<tag>
136	base = strings.Replace(base, "/releases/tag/", "/releases/download/", 1)
137
138	assetURL := base + "/" + assetName
139	sumsURL := base + "/checksums.txt"
140
141	tmp, err := os.MkdirTemp("", "zot-update-")
142	if err != nil {
143		return fmt.Errorf("create temp dir: %w", err)
144	}
145	// Best effort: remove the temp dir at the end. If extraction
146	// fails midway we leave it behind on purpose for diagnosis;
147	// users can clear /tmp themselves.
148	defer func() { _ = os.RemoveAll(tmp) }()
149
150	fmt.Println("zot update: downloading checksums.txt...")
151	sumsPath := filepath.Join(tmp, "checksums.txt")
152	if err := downloadFile(ctx, sumsURL, sumsPath); err != nil {
153		return fmt.Errorf("download checksums: %w", err)
154	}
155	wantSum, err := lookupChecksum(sumsPath, assetName)
156	if err != nil {
157		return err
158	}
159
160	fmt.Println("zot update: downloading archive...")
161	archivePath := filepath.Join(tmp, assetName)
162	if err := downloadFile(ctx, assetURL, archivePath); err != nil {
163		return fmt.Errorf("download archive: %w", err)
164	}
165
166	fmt.Println("zot update: verifying checksum...")
167	gotSum, err := sha256File(archivePath)
168	if err != nil {
169		return fmt.Errorf("hash archive: %w", err)
170	}
171	if !strings.EqualFold(gotSum, wantSum) {
172		return fmt.Errorf("checksum mismatch for %s: got %s, want %s", assetName, gotSum, wantSum)
173	}
174
175	fmt.Println("zot update: extracting...")
176	extractDir := filepath.Join(tmp, "extracted")
177	if err := os.MkdirAll(extractDir, 0o755); err != nil {
178		return fmt.Errorf("mkdir extract: %w", err)
179	}
180	if err := extractArchive(archivePath, archiveFmt, extractDir); err != nil {
181		return fmt.Errorf("extract archive: %w", err)
182	}
183
184	newBin := filepath.Join(extractDir, "zot")
185	if runtime.GOOS == "windows" {
186		newBin = filepath.Join(extractDir, "zot.exe")
187	}
188	if st, err := os.Stat(newBin); err != nil || st.IsDir() {
189		return fmt.Errorf("extracted archive does not contain a zot binary at %s", newBin)
190	}
191
192	curBin, err := os.Executable()
193	if err != nil {
194		return fmt.Errorf("resolve current binary path: %w", err)
195	}
196	// Resolve symlinks so 'zot' on $PATH that points at /usr/local/bin
197	// gets actually replaced rather than us writing into the symlink
198	// target's directory while leaving the link stale.
199	if resolved, err := filepath.EvalSymlinks(curBin); err == nil {
200		curBin = resolved
201	}
202
203	fmt.Printf("zot update: replacing %s\n", curBin)
204	if err := replaceBinary(curBin, newBin); err != nil {
205		return fmt.Errorf("replace binary: %w", err)
206	}
207	fmt.Printf("zot update: installed %s\n", latest)
208	return nil
209}
210
211// releaseAssetName returns the archive filename for the current
212// platform and the format (tar.gz / zip) used to extract it. Must
213// stay in sync with archives.name_template in .goreleaser.yaml:
214//
215//	{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}
216func releaseAssetName(version string) (name, format string, err error) {
217	goos := runtime.GOOS
218	goarch := runtime.GOARCH
219	switch goos {
220	case "linux", "darwin":
221		// supported
222	case "windows":
223		// supported
224	default:
225		return "", "", fmt.Errorf("unsupported OS for zot update: %s (download manually from the release page)", goos)
226	}
227	switch goarch {
228	case "amd64", "arm64":
229		// supported
230	default:
231		return "", "", fmt.Errorf("unsupported CPU arch for zot update: %s", goarch)
232	}
233	if goos == "windows" && goarch == "arm64" {
234		return "", "", errors.New("windows/arm64 release artifacts are not published; download manually")
235	}
236	ext := "tar.gz"
237	if goos == "windows" {
238		ext = "zip"
239	}
240	return fmt.Sprintf("zot_%s_%s_%s.%s", version, goos, goarch, ext), ext, nil
241}
242
243// downloadFile fetches url to dst, streaming through io.Copy so big
244// archives don't balloon memory.
245func downloadFile(ctx context.Context, url, dst string) error {
246	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
247	if err != nil {
248		return err
249	}
250	if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
251		req.Header.Set("authorization", "Bearer "+tok)
252	}
253	client := &http.Client{Timeout: 90 * time.Second}
254	resp, err := client.Do(req)
255	if err != nil {
256		return err
257	}
258	defer resp.Body.Close()
259	if resp.StatusCode != 200 {
260		return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
261	}
262	f, err := os.Create(dst)
263	if err != nil {
264		return err
265	}
266	defer f.Close()
267	if _, err := io.Copy(f, resp.Body); err != nil {
268		return err
269	}
270	return nil
271}
272
273// lookupChecksum parses a GoReleaser checksums.txt file and returns
274// the sha256 hex for the named asset. Format is:
275//
276//	<sha256>  <filename>
277//
278// one entry per line, two spaces between columns.
279func lookupChecksum(path, asset string) (string, error) {
280	b, err := os.ReadFile(path)
281	if err != nil {
282		return "", fmt.Errorf("read checksums: %w", err)
283	}
284	for _, line := range strings.Split(string(b), "\n") {
285		line = strings.TrimSpace(line)
286		if line == "" {
287			continue
288		}
289		fields := strings.Fields(line)
290		if len(fields) < 2 {
291			continue
292		}
293		if fields[1] == asset {
294			return fields[0], nil
295		}
296	}
297	return "", fmt.Errorf("checksum for %s not listed in checksums.txt", asset)
298}
299
300func sha256File(path string) (string, error) {
301	f, err := os.Open(path)
302	if err != nil {
303		return "", err
304	}
305	defer f.Close()
306	h := sha256.New()
307	if _, err := io.Copy(h, f); err != nil {
308		return "", err
309	}
310	return hex.EncodeToString(h.Sum(nil)), nil
311}
312
313// extractArchive shells out to the system tar / unzip rather than
314// pulling in a Go archive lib. Reasoning: zot already depends on
315// system tar implicitly in a few places, every supported platform
316// ships tar (BSD tar on macOS handles gzip natively, GNU tar on
317// Linux, bsdtar on Windows 10+), and the dependency-free release
318// archives are simple enough that we don't need format-detection
319// gymnastics.
320func extractArchive(archive, format, dst string) error {
321	switch format {
322	case "tar.gz":
323		cmd := exec.Command("tar", "-xzf", archive, "-C", dst)
324		out, err := cmd.CombinedOutput()
325		if err != nil {
326			return fmt.Errorf("tar: %v: %s", err, strings.TrimSpace(string(out)))
327		}
328		return nil
329	case "zip":
330		// PowerShell ships everywhere on supported Windows
331		// versions and Expand-Archive doesn't need elevation.
332		ps := fmt.Sprintf("Expand-Archive -LiteralPath %q -DestinationPath %q -Force", archive, dst)
333		cmd := exec.Command("powershell", "-NoProfile", "-Command", ps)
334		out, err := cmd.CombinedOutput()
335		if err != nil {
336			return fmt.Errorf("powershell Expand-Archive: %v: %s", err, strings.TrimSpace(string(out)))
337		}
338		return nil
339	default:
340		return fmt.Errorf("unknown archive format: %s", format)
341	}
342}
343
344// replaceBinary writes the new binary in place of the old one,
345// preserving the old binary's permissions. On Unix we rename
346// in-place (which works while the binary is running because the
347// kernel keeps the in-memory inode alive until the process exits).
348// On Windows the running .exe is locked, so we rename it aside
349// first, then move the new one in.
350func replaceBinary(cur, newBin string) error {
351	info, err := os.Stat(cur)
352	if err != nil {
353		return fmt.Errorf("stat current binary: %w", err)
354	}
355	mode := info.Mode().Perm()
356	if mode == 0 {
357		mode = 0o755
358	}
359
360	if runtime.GOOS == "windows" {
361		// Rename current aside so we can drop the new one in.
362		bak := cur + ".old"
363		// Clean up any stale .old from a previous update.
364		_ = os.Remove(bak)
365		if err := os.Rename(cur, bak); err != nil {
366			return fmt.Errorf("rename current to .old: %w", err)
367		}
368		if err := os.Rename(newBin, cur); err != nil {
369			// Try to put the old one back.
370			_ = os.Rename(bak, cur)
371			return fmt.Errorf("install new binary: %w", err)
372		}
373		// The .old file is locked until this process exits; leave
374		// it behind. Next `zot update` cleans it up via the
375		// os.Remove(bak) above.
376		return nil
377	}
378
379	// Unix: atomic rename if we're on the same filesystem.
380	if err := os.Rename(newBin, cur); err == nil {
381		_ = os.Chmod(cur, mode)
382		return nil
383	}
384	// Cross-fs (temp dir on tmpfs vs binary on a different mount)
385	// — fall back to copy + chmod + remove.
386	if err := copyFile(newBin, cur); err != nil {
387		return fmt.Errorf("copy new binary into place: %w", err)
388	}
389	_ = os.Chmod(cur, mode)
390	return nil
391}
392
393func copyFile(src, dst string) error {
394	in, err := os.Open(src)
395	if err != nil {
396		return err
397	}
398	defer in.Close()
399	tmp := dst + ".new"
400	out, err := os.Create(tmp)
401	if err != nil {
402		return err
403	}
404	if _, err := io.Copy(out, in); err != nil {
405		_ = out.Close()
406		_ = os.Remove(tmp)
407		return err
408	}
409	if err := out.Close(); err != nil {
410		_ = os.Remove(tmp)
411		return err
412	}
413	return os.Rename(tmp, dst)
414}
415
416// versionOnly strips the build metadata that main.go appends to the
417// version string, so "0.0.5 (43da5e5, 2026-05-12)" becomes "0.0.5".
418func versionOnly(v string) string {
419	if i := strings.IndexAny(v, " ("); i > 0 {
420		return strings.TrimSpace(v[:i])
421	}
422	return v
423}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close