package main import ( "encoding/json" "flag" "fmt" "io" "net/http" "net/url" "os" "os/exec" "runtime" "strings" "time" "nadir" "nadir/internal/config" ) // updateCmd implements `nadir update`: hit the configured Gitea repo's // releases/latest, pick the asset for the host's GOARCH, atomically replace // /usr/local/bin/nadir (or wherever the running binary lives), and restart // the systemd unit so the new code takes effect. func updateCmd(args []string) error { fs := flag.NewFlagSet("update", flag.ExitOnError) check := fs.Bool("check", false, "report the latest version without downloading") force := fs.Bool("force", false, "re-download even when already at the latest version") fs.Parse(args) configPath, err := resolveConfigPath() if err != nil { return err } cfg, err := config.Load(configPath) if err != nil { return err } if cfg.Server.ReleaseRepo == "" { return fmt.Errorf("server.release_repo not set in %s", configPath) } apiURL, err := releaseAPIURL(cfg.Server.ReleaseRepo) if err != nil { return err } suffix := "linux-" + runtime.GOARCH fmt.Printf("querying %s ...\n", apiURL) rel, err := fetchLatestRelease(apiURL) if err != nil { return err } fmt.Printf("current: %s\nlatest: %s\n", nadir.Version, rel.TagName) upToDate := nadir.Version == rel.TagName switch { case *check: if upToDate { fmt.Println("already up to date.") } else { fmt.Println("update available; run `nadir update` to install.") } return nil case upToDate && !*force: fmt.Println("already up to date; pass --force to re-download.") return nil } var assetURL, assetName string for _, a := range rel.Assets { if strings.HasSuffix(a.Name, suffix) { assetURL, assetName = a.URL, a.Name break } } if assetURL == "" { return fmt.Errorf("no %s asset in release %s", suffix, rel.TagName) } exe, err := os.Executable() if err != nil { return err } fmt.Printf("downloading %s (%s) ...\n", assetName, rel.TagName) tmp := exe + ".tmp" if err := download(assetURL, tmp); err != nil { os.Remove(tmp) return err } // Atomic on the same filesystem; replaces the on-disk file without // disturbing the still-running process (its inode stays alive). if err := os.Rename(tmp, exe); err != nil { os.Remove(tmp) return err } fmt.Printf("installed %s at %s\n", rel.TagName, exe) fmt.Println("restarting service ...") if err := exec.Command("systemctl", "restart", serviceName).Run(); err != nil { fmt.Fprintf(os.Stderr, "warning: could not restart %s service (%v); restart it manually to pick up the new binary\n", serviceName, err) } return nil } type giteaRelease struct { TagName string `json:"tag_name"` Assets []struct { Name string `json:"name"` URL string `json:"browser_download_url"` } `json:"assets"` } func fetchLatestRelease(apiURL string) (*giteaRelease, error) { c := &http.Client{Timeout: 30 * time.Second} resp, err := c.Get(apiURL) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("releases API returned %s", resp.Status) } var r giteaRelease if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return nil, fmt.Errorf("decode release JSON: %w", err) } return &r, nil } // releaseAPIURL converts https://host/owner/repo (the same URL pasted into // release_repo and shown in a browser) into the Gitea releases/latest API // endpoint: https://host/api/v1/repos/owner/repo/releases/latest. func releaseAPIURL(repoURL string) (string, error) { u, err := url.Parse(repoURL) if err != nil { return "", fmt.Errorf("release_repo: %w", err) } parts := strings.Split(strings.Trim(u.Path, "/"), "/") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", fmt.Errorf("release_repo must look like https://host/owner/repo, got %q", repoURL) } return fmt.Sprintf("%s://%s/api/v1/repos/%s/%s/releases/latest", u.Scheme, u.Host, parts[0], parts[1]), nil } func download(srcURL, dst string) error { c := &http.Client{Timeout: 5 * time.Minute} resp, err := c.Get(srcURL) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download %s: %s", srcURL, resp.Status) } f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) if err != nil { return err } defer f.Close() if _, err := io.Copy(f, resp.Body); err != nil { return err } return nil }