Files
magpie/update_cli.go
T
yetone 1734d2b33d update: magpie web in a Docker container says to pull the new image instead of only linking the release page, and magpie update in one says the same instead of failing on the write (Chapin on Discord: 网页端的更新按钮…跳转到github)
The image runs /magpie as nonroot (uid 65532) in a read-only distroless root with no pkexec, so the updater's placeExe found the binary's folder unwritable and no way to elevate: stuck "not-writable", and the page said to run `sudo magpie update` or reinstall from the release page, neither of which works in a container (no sudo, and a replaced binary is lost when the container is recreated). The Download button there opens the release page, which is what Chapin saw if they run the image. Now, when the folder can't be written, nothing can elevate, and gateway.InContainer() (the existing .dockerenv / run/.containerenv / cgroup check, now exported) says this is a container, the stuck reason is "container" and the page says: pull ghcr.io/yetone/magpie:latest and recreate the container (new string in en, zh, zh-TW, ja, de). `magpie update` in such a container fails up front with the same advice, instead of downloading and then failing to replace itself. A writable binary in a container (a user's own image) still updates itself as before.

Tested: TestUpdateInContainerSaysPullTheImage in internal/gui (unwritable folder in a container: stuck "container", no StuckDir; outside one: not-writable with the dir; writable folder in a container: the exe is taken) and in package main (in a container the error carries the docker pull advice; outside one it doesn't). Both fail with the container check taken out (gui: Stuck not-writable; main: "the release lists no checksum"). update-stuck.test.cjs now has the container case in en/zh/zh-TW/ja/de; with the app.js line removed it fails 11 of 20; with it, 20/20 in Chromium and WebKit. i18n-keys, gui-ja, gui-de, gui-zh-tw, click-scroll and every update-* GUI test pass in both engines. Real run: the image built from this tree (VERSION=0.1.10), `magpie web` in it with MAGPIE_UPDATE_FEED at a host feed listing 0.1.11: /api/update answered state available, stuck "container"; `docker exec … /magpie update` printed the pull-the-image message and exited 1, the binary stayed 0.1.10. gofmt clean; go vet on ., ./internal/gui/, ./internal/gateway/ for darwin, GOOS=linux and GOOS=windows -tags nogui; full go test -tags nogui ./... under a temp HOME. The page's look in a real browser against the container was not looked at (the GUI test renders the same pill).
2026-10-09 10:43:14 +08:00

215 lines
7.1 KiB
Go

package main
import (
"context"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/update"
)
// updateExecutable, updateCanElevate and updateInContainer are
// update.Executable, update.CanElevate and gateway.InContainer; tests stand
// in for them.
var (
updateExecutable = update.Executable
updateCanElevate = update.CanElevate
updateInContainer = gateway.InContainer
)
// updateEveries are the intervals magpie update auto takes, by their
// minutes in settings.UpdateEveries.
var updateEveries = map[string]int{"30m": 30, "1h": 60, "6h": 360, "24h": 1440}
// updateAutoCmd is `magpie update auto [on|off] [30m|1h|6h|24h]`: whether
// the app asks for a newer version (and downloads it) by itself, and how
// often (#472). With neither, it says how it is set.
func updateAutoCmd(args []string) error {
s := settings.Load()
for _, a := range args {
switch a {
case "on", "off":
s.NoAutoUpdate = a == "off"
default:
m, ok := updateEveries[a]
if !ok {
return fmt.Errorf("magpie update auto takes on, off or how often (30m, 1h, 6h or 24h), not %q", a)
}
s.UpdateEvery = m
}
}
if len(args) > 0 {
if err := settings.Save(s); err != nil {
return err
}
}
every := "6h"
for k, m := range updateEveries {
if m == s.UpdateEvery {
every = k
}
}
if s.NoAutoUpdate {
fmt.Println("automatic updates are off", muted.Render("· magpie update or Settings' Check looks for one (every "+every+" when on)"))
return nil
}
fmt.Println("automatic updates are on", muted.Render("· magpie looks for a newer version every "+every+" and downloads it"))
return nil
}
// updateMirrorCmd is `magpie update mirror [<prefix>|off]`: the GitHub
// download mirror every update goes through, the app's own among them
// (settings.UpdateMirror). With nothing, it says which.
func updateMirrorCmd(args []string) error {
s := settings.Load()
if len(args) > 1 {
return fmt.Errorf("magpie update mirror takes one prefix, or off")
}
if len(args) == 1 {
switch m := strings.TrimSpace(args[0]); m {
case "off", "none", "":
s.UpdateMirror = ""
default:
if err := update.CheckMirror(m); err != nil {
return err
}
s.UpdateMirror = m
}
if err := settings.Save(s); err != nil {
return err
}
}
if s.UpdateMirror == "" {
fmt.Println("updates are downloaded from GitHub", muted.Render("· magpie update mirror <prefix> has them come through a mirror; the checksum is still usemagpie.ai's"))
return nil
}
fmt.Println("updates are downloaded through", bold.Render(s.UpdateMirror), muted.Render("· each is checked against usemagpie.ai's checksum · magpie update mirror off goes back to GitHub"))
return nil
}
// updateFlags takes --proxy and --mirror (each as --flag value or
// --flag=value) out of args.
func updateFlags(args []string) (rest []string, proxy, mirror string, err error) {
for i := 0; i < len(args); i++ {
a := args[i]
name, val, eq := strings.Cut(a, "=")
if name != "--proxy" && name != "--mirror" {
rest = append(rest, a)
continue
}
if !eq {
if i+1 >= len(args) {
return nil, "", "", fmt.Errorf("%s needs a value", name)
}
i++
val = args[i]
}
if name == "--proxy" {
if err := update.CheckProxy(val); err != nil {
return nil, "", "", err
}
proxy = val
continue
}
if val != "off" {
if err := update.CheckMirror(val); err != nil {
return nil, "", "", err
}
}
mirror = val
}
return rest, proxy, mirror, nil
}
// updateCmd is `magpie update [check] [--proxy <url>] [--mirror <prefix>]`:
// the app replaces its bundle, the terminal build its binary.
func updateCmd(args []string) error {
args, proxy, mirror, err := updateFlags(args)
if err != nil {
return err
}
if len(args) > 1 && args[1] == "auto" {
return updateAutoCmd(args[2:])
}
if len(args) > 1 && args[1] == "mirror" {
return updateMirrorCmd(args[2:])
}
if len(args) > 2 || len(args) == 2 && args[1] != "check" {
return fmt.Errorf("usage: magpie update [check] [--proxy <url>] [--mirror <prefix>]")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
ctx = update.WithMirror(update.WithProxy(ctx, proxy), mirror)
rel, err := update.Latest(ctx)
if err != nil {
return err
}
if !update.Newer(rel.Version, version) {
if update.Released(version) {
fmt.Println(green.Render("✓"), "magpie", version, muted.Render("is the latest"))
} else {
fmt.Println("magpie", version, muted.Render("was built from source; the latest release is "+rel.Version))
}
return nil
}
fmt.Println("magpie", bold.Render(rel.Version), "is out", muted.Render("(you have "+version+") · "+rel.URL))
if len(args) > 1 && args[1] == "check" {
return nil
}
if !update.Released(version) {
return fmt.Errorf("this magpie was built from source; update it the way you built it, or get the release from %s", update.Site)
}
if app := update.Bundle(); app != "" {
if update.Stuck(app) != "" {
return fmt.Errorf("%s can't be replaced where it is; move magpie to Applications, or download the new version from %s", tilde(app), update.Site)
}
fmt.Println(muted.Render(" downloading " + update.AppAsset() + via(ctx) + " …"))
staged, err := update.Stage(ctx, rel, app)
if err != nil {
return err
}
err = update.Install(staged, app)
if update.NeedsAdmin(err) {
err = update.InstallAsAdmin(staged, app)
}
if err != nil {
return err
}
fmt.Println(green.Render("✓"), "updated", tilde(app), "to", rel.Version, muted.Render("· quit and reopen magpie to use it"))
if stale := update.StaleCLI(rel.Version); stale != "" {
// The copy won't follow the app; the fix is the link install.sh
// makes. `magpie update` run from the copy can't do it (it
// replaces the copy with another copy), so name the real step.
bin := filepath.Join(app, "Contents", "MacOS", "magpie")
fmt.Println(muted.Render(" the `magpie` command at " + tilde(stale) + " is a copy behind this app; re-run the installer, or link it: ln -sf " + tilde(bin) + " " + tilde(stale)))
}
return nil
}
// the Docker image's /magpie, run as nonroot: the image is what is
// updated, and a replace would only fail with "permission denied"
if exe, err := updateExecutable(); err == nil && !update.Writable(filepath.Dir(exe)) && !updateCanElevate() && updateInContainer() {
return fmt.Errorf("this magpie runs in a container and can't replace itself; pull the new image (docker pull ghcr.io/yetone/magpie:latest) and recreate the container")
}
if exe, err := update.Executable(); err == nil && update.Homebrew(exe) {
return fmt.Errorf("this magpie was installed with Homebrew; update it with: brew upgrade magpie")
}
fmt.Println(muted.Render(" downloading " + update.BinaryAsset() + via(ctx) + " …"))
if err := update.ReplaceBinary(ctx, rel); err != nil {
return err
}
fmt.Println(green.Render("✓"), "updated to", rel.Version)
return nil
}
// via says the mirror a download goes through, if any.
func via(ctx context.Context) string {
if m := update.Mirror(ctx); m != "" {
return " through " + m
}
return ""
}