Files
magpie/update_cli.go
T
erlinerdandClaude Code 94008ce66f update: say so when the magpie command is a copy the app left behind (#623)
* update: say so when the `magpie` command is a copy the app left behind

install.sh links the terminal's magpie into ~/.local/bin to the app's
own binary, so it follows every GUI update for free. A command that
arrived as a copied file — an older installer, a hand cp — keeps
running its own build once magpie.app moves on, and a command behind
the app is a stale build: one that predates the WebDAV short-write
check (#531) truncated a remote backup on `magpie webdav now`, wedging
the sync on every tick after. The gap is macOS-only: on Linux the app
and the command are one file, and its update replaces it in place.

magpie doesn't rewrite the user's PATH itself — that's the installer's
job, and a background update has no business reaching there. What the
command *is* says whether it can follow the app, so update.StaleCLI is
a plain Lstat of the installer's own convention (MAGPIE_BIN_DIR, else
~/.local/bin): a regular file is behind, a link isn't, and nothing
runs the old binary to ask its version. When it's behind, magpie says
so where the user can see it: /api/state carries cliBehind and the
window's status line says it once per run (the panel stays quiet, too
small for advice), and `magpie update` adds the line after updating
the app it found through the same copied command.

TestStaleCLI pins the three file states over a temp bin dir;
cli-behind.test.cjs (WebKit and Chromium, en and zh) fakes a state
with cliBehind and checks the window says it, the panel doesn't, and
a following command is never mentioned. go vet, and go test -tags
nogui ./internal/update ./internal/gui under a temp HOME pass.

* update: only say the command is behind when it is, and how to fix it

Two things the review caught about the notice.

A copy that isn't actually behind the app shouldn't nag. The
standalone magpie-cli build is a legitimate regular file in
~/.local/bin beside the app, and the first cut warned about it on
every window load however current it was. StaleCLI now takes the
app's version and stays quiet unless the copy is a release that
trails it — a copy at or ahead of the app, a build from source, and
one that won't run are all left be. Reading the version runs the old
binary, whose start-up migrates things, but that's the only way to
tell a stale release from a current one, and it's reached only for a
file already known by Lstat to be a copy, once per run.

The advice named a step that didn't fix it. `magpie update` run from
a copied command has no bundle to update (the copy isn't inside a
.app), so it replaces the copy with another copy and the warning
comes back. Both the window's line and `magpie update`'s now point
at what actually works: re-run the installer, or link the command by
hand — `ln -sf <app>/Contents/MacOS/magpie <cli>`, the link
install.sh makes.

TestStaleCLI covers the version decision (behind → say, current,
ahead, source, unreadable → quiet) over a temp bin dir; the browser
test's wording follows. Verified by mutation: dropping the version
guard turns the stale case red, and Stat for Lstat turns the link
case red. go vet, and go test -tags nogui ./internal/update
./internal/gui under a temp HOME pass.

* gui: the window's copied-command advice is an Lstat with a per-version dismiss (yetone's review on #623)

What: the window no longer runs the old binary's `version` to tell a stale
copy from a current one. update.CopiedCLI is a bare Lstat, reached on every
launch; the version read stays in update.StaleCLI, which only `magpie
update` calls. The advice moved from a 9s status line to a small dialog
with "Hide until the next version", kept in settings' cli-behind-quiet
file as the version it was dismissed at, so the next app update asks once
more. POST /api/cli-behind/quiet writes it.

Why: `magpie version` isn't read-only — run() migrates settings before the
command answers, so an old copy rewrote the user's real config with the old
build's migrations (old MoveOffAccountIDs could drop fields), and the read
on the first /api/state cost up to 2s on a cold start. An Lstat can't tell
whether the copy trails, so what the window says is advice, not a warning
that knows — dismissable, and worded as a copy rather than a stale one.

Tests: TestCLIBehindIsLstatOnlyAndDismissible (a non-executable file is
reported — nothing ran; dismiss quiets it and the file holds this version;
the installer's symlink is quiet). cli-behind.test.cjs covers the dialog,
its dismiss POST and the quiet panel, chromium+webkit, en+zh, 4/4.
go vet, go build -tags nogui (darwin, GOOS=linux, GOOS=windows) and
go test -tags nogui -count=1 ./... under a temp HOME pass (netproxy's
TestBunThroughBridge fails the same way on the branch without this change).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* gui: the dismiss survives a restart, and cliBehindVal is behind a lock (yetone's review on #623)

What: the POST handler wrote cliBehindVal = "" while state() read it on
other goroutines with no lock — a page dismissing while another view
polled /api/state raced. cliBehindVal now sits behind cliBehindMu on
both sides. The test also proves the promise the handler's one-line
clear couldn't: after a simulated restart (fresh Once, empty value)
the dismissed advice stays quiet, and the next version asks once more.

Why: the restart case was untested — short-circuiting cliQuiet() left
the suite green, because the handler's in-memory clear alone satisfied
it. The quiet file is what actually carries the dismiss across runs.

Tests: TestCLIBehindIsLstatOnlyAndDismissible extended with the
restart and next-version legs; `if false && cliQuiet()` now fails it
(mutation-checked). go vet, go test -tags nogui -race ./internal/gui
./internal/update pass; TestGatewayTakenOver fails the same way with
-race on the unmodified branch (pre-existing).

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-03 13:32:42 +08:00

116 lines
3.9 KiB
Go

package main
import (
"context"
"fmt"
"path/filepath"
"time"
"github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/update"
)
// 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
}
// updateCmd is `magpie update [check]`: the app replaces its bundle, the
// terminal build its binary.
func updateCmd(args []string) error {
if len(args) > 1 && args[1] == "auto" {
return updateAutoCmd(args[2:])
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
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() + " …"))
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
}
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() + " …"))
if err := update.ReplaceBinary(ctx, rel); err != nil {
return err
}
fmt.Println(green.Render("✓"), "updated to", rel.Version)
return nil
}