agent: add 'zot update' subcommand for in-place self-update
Adds a CLI subcommand that downloads the latest GitHub release for the current GOOS/GOARCH, verifies its sha256 against checksums.txt, extracts the archive, and atomically replaces the running binary. zot update install the latest release zot update --check show whether one is available, install nothing zot update --help usage Dispatch follows the same router shape as runBotCommand / runExtCommand in cli.go. Asset naming stays in sync with the archives.name_template in .goreleaser.yaml (zot_<ver>_<os>_<arch>). Reuses fetchLatestRelease + versionLess from update.go so the "what's latest" answer is identical to the in-TUI banner. Refuses to operate on dev builds (version 0.0.0) since the version comparison is meaningless and we'd happily downgrade a freshly compiled local build back to whatever ships on GitHub. $GITHUB_TOKEN is honoured so private-repo releases work. Unix: atomic os.Rename in place (the kernel keeps the running binary's inode alive until exit). Windows: rename current aside to .old, drop the new exe in, leave the .old for next-update cleanup since the running process has it locked.
- commit
- f47996bc8230e48f4543f7ac31529aa0439a369c
- author
- patriceckhart · · 2026-05-12 19:27 UTC
- parent
- 43da5e5
- signature
- unsigned
- refs
- tag: v0.1.78
3 files · +430 -0
M internal/agent/args.go +4 -0
| @@ -323,6 +323,10 @@ func PrintHelp(version string) { | |||
| 323 | 323 | row{"zot --ext ./path/to/ext", "load an extension for this run only"}, | |
| 324 | 324 | row{"zot ext help", "show all extension subcommands"}, | |
| 325 | 325 | ) | |
| 326 | + | section("self-update", | |
| 327 | + | row{"zot update", "download and install the latest release"}, | |
| 328 | + | row{"zot update --check", "show whether a new release is available"}, | |
| 329 | + | ) | |
| 326 | 330 | section("telegram", | |
| 327 | 331 | row{"zot telegram-bot setup", "configure a telegram bot (from BotFather)"}, | |
| 328 | 332 | row{"zot telegram-bot run", "foreground bridge (ctrl+c to stop)"}, | |
| @@ -323,6 +323,10 @@ func PrintHelp(version string) { | |||||
| 323 | row{"zot --ext ./path/to/ext", "load an extension for this run only"}, | 323 | row{"zot --ext ./path/to/ext", "load an extension for this run only"}, | ||
| 324 | row{"zot ext help", "show all extension subcommands"}, | 324 | row{"zot ext help", "show all extension subcommands"}, | ||
| 325 | ) | 325 | ) | ||
| 326 | + | section("self-update", | |||
| 327 | + | row{"zot update", "download and install the latest release"}, | |||
| 328 | + | row{"zot update --check", "show whether a new release is available"}, | |||
| 329 | + | ) | |||
| 326 | section("telegram", | 330 | section("telegram", | ||
| 327 | row{"zot telegram-bot setup", "configure a telegram bot (from BotFather)"}, | 331 | row{"zot telegram-bot setup", "configure a telegram bot (from BotFather)"}, | ||
| 328 | row{"zot telegram-bot run", "foreground bridge (ctrl+c to stop)"}, | 332 | row{"zot telegram-bot run", "foreground bridge (ctrl+c to stop)"}, | ||
M internal/agent/cli.go +3 -0
| @@ -172,6 +172,9 @@ func Run(rawArgs []string, version string) error { | |||
| 172 | 172 | if handled, err := runExtCommand(rawArgs); handled { | |
| 173 | 173 | return err | |
| 174 | 174 | } | |
| 175 | + | if handled, err := runUpdateCommand(rawArgs, version); handled { | |
| 176 | + | return err | |
| 177 | + | } | |
| 175 | 178 | // `zot rpc` is shorthand for `zot --rpc` so third-party apps can | |
| 176 | 179 | // spawn the binary with a clean argv. Strip the leading 'rpc' | |
| 177 | 180 | // token and let the rest flow through the normal arg parser. | |
| @@ -172,6 +172,9 @@ func Run(rawArgs []string, version string) error { | |||||
| 172 | if handled, err := runExtCommand(rawArgs); handled { | 172 | if handled, err := runExtCommand(rawArgs); handled { | ||
| 173 | return err | 173 | return err | ||
| 174 | } | 174 | } | ||
| 175 | + | if handled, err := runUpdateCommand(rawArgs, version); handled { | |||
| 176 | + | return err | |||
| 177 | + | } | |||
| 175 | // `zot rpc` is shorthand for `zot --rpc` so third-party apps can | 178 | // `zot rpc` is shorthand for `zot --rpc` so third-party apps can | ||
| 176 | // spawn the binary with a clean argv. Strip the leading 'rpc' | 179 | // spawn the binary with a clean argv. Strip the leading 'rpc' | ||
| 177 | // token and let the rest flow through the normal arg parser. | 180 | // token and let the rest flow through the normal arg parser. | ||
A internal/agent/updatecmd.go +423 -0
| @@ -0,0 +1,423 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 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. | |
| 40 | + | func 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 | + | ||
| 60 | + | func printUpdateHelp() { | |
| 61 | + | fmt.Fprintln(os.Stderr, `zot update — replace the current zot binary with the latest release | |
| 62 | + | ||
| 63 | + | usage: | |
| 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 | + | ||
| 68 | + | notes: | |
| 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. | |
| 80 | + | func 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`. | |
| 102 | + | func 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 }} | |
| 216 | + | func 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. | |
| 245 | + | func 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. | |
| 279 | + | func 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 | + | ||
| 300 | + | func 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. | |
| 320 | + | func 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. | |
| 350 | + | func 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 | + | ||
| 393 | + | func 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". | |
| 418 | + | func versionOnly(v string) string { | |
| 419 | + | if i := strings.IndexAny(v, " ("); i > 0 { | |
| 420 | + | return strings.TrimSpace(v[:i]) | |
| 421 | + | } | |
| 422 | + | return v | |
| 423 | + | } | |
| @@ -0,0 +1,423 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 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. | |||
| 40 | + | func 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 | + | ||||
| 60 | + | func printUpdateHelp() { | |||
| 61 | + | fmt.Fprintln(os.Stderr, `zot update — replace the current zot binary with the latest release | |||
| 62 | + | ||||
| 63 | + | usage: | |||
| 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 | + | ||||
| 68 | + | notes: | |||
| 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. | |||
| 80 | + | func 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`. | |||
| 102 | + | func 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 }} | |||
| 216 | + | func 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. | |||
| 245 | + | func 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. | |||
| 279 | + | func 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 | + | ||||
| 300 | + | func 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. | |||
| 320 | + | func 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. | |||
| 350 | + | func 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 | + | ||||
| 393 | + | func 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". | |||
| 418 | + | func versionOnly(v string) string { | |||
| 419 | + | if i := strings.IndexAny(v, " ("); i > 0 { | |||
| 420 | + | return strings.TrimSpace(v[:i]) | |||
| 421 | + | } | |||
| 422 | + | return v | |||
| 423 | + | } | |||