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