feat: update on cli
build-and-release / release (push) Successful in 2m6s

This commit is contained in:
2026-06-22 17:51:19 +02:00
parent 604d3e8ba4
commit eeb85bbd8f
3 changed files with 147 additions and 0 deletions
+3
View File
@@ -102,6 +102,9 @@ func main() {
case "token": case "token":
ensureRoot() ensureRoot()
fatalIf(tokenCmd(args)) fatalIf(tokenCmd(args))
case "update":
ensureRoot()
fatalIf(updateCmd(args))
case "help", "-h", "--help": case "help", "-h", "--help":
usage(os.Stdout) usage(os.Stdout)
default: default:
+1
View File
@@ -425,6 +425,7 @@ Usage:
nadir token add <name> Mint a machine credential (Bearer token), shown once nadir token add <name> Mint a machine credential (Bearer token), shown once
nadir token rm <name> Revoke a token (effective immediately, no restart) nadir token rm <name> Revoke a token (effective immediately, no restart)
nadir token ls List token names and when they were created nadir token ls List token names and when they were created
nadir update Fetch the latest release from server.release_repo and restart
nadir help Show this help nadir help Show this help
Most commands need root. Config path is specified via -f/--config or CONFIG_PATH (default ~/.config/config.yaml). Most commands need root. Config path is specified via -f/--config or CONFIG_PATH (default ~/.config/config.yaml).
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"strings"
"time"
"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(_ []string) error {
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
}
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
}