update: install.sh and magpie update take --proxy and --mirror, and magpie update mirror keeps one for the app's updates too (akic404 on Discord: 安装和update如果没有梯子也是麻烦事,能否在install.sh和magpie update后加子选项?--proxy等可自定义或默认ghproxy) — install.sh had no options and fetched with curl alone, and magpie update took none; without a way out to GitHub both failed. Now both take --proxy <url> (http, https, socks5, socks5h; install.sh also MAGPIE_PROXY=) for the feed and the download, and --mirror <prefix> (or MAGPIE_MIRROR=), a GitHub download mirror the release file is fetched from as <prefix>https://github.com/…; only github.com / githubusercontent.com URLs are put through it. No mirror is used unless one is given: magpie names none and has none by default. The feed with each file's SHA-256 is always fetched from usemagpie.ai, never through the mirror, so a file the mirror changed is refused and deleted ("… from the mirror X does not match its checksum from https://usemagpie.ai; not installed"); macOS still checks the app's signing team. magpie update mirror <prefix>|off saves Settings' updateMirror (http(s) only), which the app's own background update follows too; magpie update --mirror off skips it once; the Settings page's save keeps it. The app's update already followed Settings → Proxy (#294); magpie update --proxy overrides it for one run. The environment's ALL_PROXY / all_proxy is now honoured when HTTPS_PROXY / HTTP_PROXY are unset (Go's ProxyFromEnvironment ignores it), with NO_PROXY as curl reads it. magpie update takes --flag value or --flag=value, and an unknown word is an error instead of an update. The help, README and the docs' FAQ (en/zh/ja) say how. install.test.mjs's proxy, mirror and tampered-mirror tests, TestAllProxyFromTheEnvironment, TestUpdateThroughAMirror, TestUpdateThroughAProxyGiven and TestUpdateFlags fail on the old code and pass; TestSettingsSaveKeepsWhatItDoesNotSend covers updateMirror. A dry run of install.sh as Linux amd64 into a temp HOME installed the real 0.1.773 with its SHA-256 checked, refused a local mirror serving a wrong file, and with --proxy to a closed port failed reaching the site through it. go vet, builds (darwin, GOOS=linux, GOOS=windows), sh -n, node --test site/install.test.mjs and go test -tags nogui -count=1 ./... under a temp HOME pass but TestClaudeIdentityServedWhileAsked, a timing test that passes alone. Not tried through a real proxy server or a real public mirror; no install.ps1 exists for Windows, and the app has no field for the mirror.
This commit is contained in:
1 parent
38a0c1f053
commit
bc141ad834
18 files changed
+688
-18
No files matched your search
@@ -688,6 +688,21 @@ otherwise):
|
||||
curl -fsSL https://usemagpie.ai/install.sh | sh
|
||||
```
|
||||
|
||||
Behind a firewall, or without access to GitHub, both the installer and
|
||||
`magpie update` take `--proxy <url>` (http, https, socks5, socks5h; or
|
||||
`MAGPIE_PROXY`, and `HTTPS_PROXY` / `ALL_PROXY` as usual) and
|
||||
`--mirror <prefix>`, a GitHub download mirror of your choosing put before
|
||||
the release's github.com URL (or `MAGPIE_MIRROR`; `magpie update mirror
|
||||
<prefix>|off` keeps one for every update, the app's too):
|
||||
|
||||
```sh
|
||||
curl -fsSL https://usemagpie.ai/install.sh | sh -s -- --proxy http://127.0.0.1:7890
|
||||
magpie update --mirror https://mirror.example/
|
||||
```
|
||||
|
||||
No mirror is used unless you give one, and the SHA-256 every download is
|
||||
checked against still comes from usemagpie.ai, never from the mirror.
|
||||
|
||||
Mac releases are signed and notarised; the Windows and Linux builds are not
|
||||
signed yet (Windows SmartScreen may ask before the first run). Every build
|
||||
keeps itself current: the app
|
||||
|
||||
@@ -769,6 +769,8 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
|
||||
in.TextSize = cur.TextSize
|
||||
// the version the Update pill was hidden for, set from the pill
|
||||
in.UpdateSkip = cur.UpdateSkip
|
||||
// the GitHub mirror updates come through, set by magpie update mirror
|
||||
in.UpdateMirror = cur.UpdateMirror
|
||||
if v := strings.TrimSpace(in.Vision); v != "" && v != "off" && v != cur.Vision {
|
||||
if _, _, ok := provider.Resolve(v); !ok {
|
||||
fail(rw, fmt.Errorf("no model %s to describe images", v))
|
||||
|
||||
@@ -136,6 +136,7 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) {
|
||||
CodexAutoReset: []string{"me@example.com"},
|
||||
TextSize: 125,
|
||||
UpdateSkip: "0.1.500",
|
||||
UpdateMirror: "https://mirror.example/",
|
||||
Window: []int{900, 700},
|
||||
}
|
||||
if err := settings.Save(was); err != nil {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package netproxy
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ALL_PROXY, which curl reads and Go doesn't, is magpie's too when neither
|
||||
// HTTPS_PROXY nor HTTP_PROXY is set: a shell with only
|
||||
// ALL_PROXY=socks5://… gets magpie update through it (akic404 on Discord),
|
||||
// and NO_PROXY still keeps hosts off it.
|
||||
func TestAllProxyFromTheEnvironment(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
for _, k := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
sysCache.at, sysCache.p = farFuture, Proxy{}
|
||||
t.Cleanup(func() { sysCache.at = zero })
|
||||
|
||||
t.Setenv("all_proxy", "socks5h://127.0.0.1:1080")
|
||||
t.Setenv("NO_PROXY", "internal.example, .corp.example:443")
|
||||
gh, _ := url.Parse("https://github.com/yetone/magpie-releases/releases/download/v1/magpie")
|
||||
if p, err := For(gh); err != nil || p == nil || p.String() != "socks5h://127.0.0.1:1080" {
|
||||
t.Fatalf("ALL_PROXY: %v %v", p, err)
|
||||
}
|
||||
if proxy, source := Describe(); proxy != "socks5h://127.0.0.1:1080" || source != "environment" {
|
||||
t.Fatalf("described as %q (%s)", proxy, source)
|
||||
}
|
||||
for _, raw := range []string{"https://internal.example/x", "https://api.internal.example/x", "https://git.corp.example/x", "http://127.0.0.1:3/x"} {
|
||||
u, _ := url.Parse(raw)
|
||||
if p, _ := For(u); p != nil {
|
||||
t.Errorf("%s went through %v despite NO_PROXY", raw, p)
|
||||
}
|
||||
}
|
||||
t.Setenv("NO_PROXY", "*")
|
||||
if p, _ := For(gh); p != nil {
|
||||
t.Fatalf("NO_PROXY=* still proxied: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoProxyList(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
host, list string
|
||||
want bool
|
||||
}{
|
||||
{"github.com", "", false},
|
||||
{"github.com", "github.com", true},
|
||||
{"objects.github.com", "github.com", true},
|
||||
{"notgithub.com", "github.com", false},
|
||||
{"api.example.com", "*.example.com", true},
|
||||
{"example.com", ".example.com", true},
|
||||
{"example.com", "example.com:443", true},
|
||||
{"localhost", "", true},
|
||||
} {
|
||||
if got := noProxy(c.host, c.list); got != c.want {
|
||||
t.Errorf("noProxy(%q, %q) = %v, want %v", c.host, c.list, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,7 +264,55 @@ func Describe() (proxy, source string) {
|
||||
return "", "none"
|
||||
}
|
||||
|
||||
func fromEnv(u *url.URL) (*url.URL, error) { return http.ProxyFromEnvironment(&http.Request{URL: u}) }
|
||||
// fromEnv is the proxy the environment names for u: HTTPS_PROXY or
|
||||
// HTTP_PROXY as Go reads them, else ALL_PROXY (curl's, which Go doesn't
|
||||
// read: a shell with only ALL_PROXY=socks5://… set has curl go through it,
|
||||
// and magpie, its updates among its requests, too). NO_PROXY is kept for
|
||||
// it as well.
|
||||
func fromEnv(u *url.URL) (*url.URL, error) {
|
||||
if p, err := http.ProxyFromEnvironment(&http.Request{URL: u}); p != nil || err != nil {
|
||||
return p, err
|
||||
}
|
||||
all := envOf("ALL_PROXY", "all_proxy")
|
||||
if all == "" || noProxy(u.Hostname(), envOf("NO_PROXY", "no_proxy")) {
|
||||
return nil, nil
|
||||
}
|
||||
return Parse(all)
|
||||
}
|
||||
|
||||
func envOf(keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// noProxy matches host against NO_PROXY as curl does: "*" for every host,
|
||||
// else entries apart by commas, each the host or a domain it is under
|
||||
// (example.com and .example.com both take api.example.com); a port on an
|
||||
// entry is ignored. Loopback is never proxied anyway.
|
||||
func noProxy(host, list string) bool {
|
||||
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||
if host == "" || loopback(host) {
|
||||
return true
|
||||
}
|
||||
for _, e := range strings.Split(list, ",") {
|
||||
e = strings.ToLower(strings.TrimSpace(e))
|
||||
if e == "*" {
|
||||
return true
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(e); err == nil {
|
||||
e = h
|
||||
}
|
||||
e = strings.TrimPrefix(strings.TrimPrefix(e, "*"), ".")
|
||||
if e != "" && (host == e || strings.HasSuffix(host, "."+e)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Proxy is the system's proxy: one address, and the hosts that skip it.
|
||||
type Proxy struct {
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestParseWindows(t *testing.T) {
|
||||
func TestFor(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
for _, k := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "NO_PROXY", "no_proxy"} {
|
||||
for _, k := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
sysCache.at, sysCache.p = farFuture, Proxy{URL: "http://127.0.0.1:7890", Bypass: []string{"*.corp"}}
|
||||
|
||||
@@ -129,6 +129,13 @@ type Settings struct {
|
||||
// one of UpdateEveries, 0 for every six hours.
|
||||
NoAutoUpdate bool `json:"noAutoUpdate,omitempty"`
|
||||
UpdateEvery int `json:"updateEvery,omitempty"`
|
||||
// UpdateMirror is a GitHub download mirror an update's file is fetched
|
||||
// through, its prefix put before the release's github.com URL
|
||||
// (https://mirror.example/https://github.com/…): "" for none, which is
|
||||
// the default — magpie names no mirror of its own. The file is still
|
||||
// checked against the SHA-256 the update feed at usemagpie.ai gives,
|
||||
// never one from the mirror. magpie update mirror sets it.
|
||||
UpdateMirror string `json:"updateMirror,omitempty"`
|
||||
// Vision is the model that describes an image to a model that can't see
|
||||
// it: a model's id (provider/model, group/<id>), "off" to turn such an
|
||||
// image away, or empty for one magpie picks (see gateway.seer).
|
||||
@@ -607,6 +614,11 @@ func Save(s Settings) error {
|
||||
if !slices.Contains(UpdateEveries, s.UpdateEvery) {
|
||||
return fmt.Errorf("magpie checks for updates every %v minutes, not %d", UpdateEveries, s.UpdateEvery)
|
||||
}
|
||||
if m := s.UpdateMirror; m != "" {
|
||||
if u, err := url.Parse(m); err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||||
return fmt.Errorf("an update mirror is an http(s) address put before the github.com URL, like https://mirror.example/, not %q", m)
|
||||
}
|
||||
}
|
||||
if s.UsageAlert < 0 || s.UsageAlert > 100 {
|
||||
return fmt.Errorf("a usage alert is at a percentage from 1 to 100, or 0 for off, not %d", s.UsageAlert)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/yetone/magpie/internal/netproxy"
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
)
|
||||
|
||||
// A network that can't reach GitHub can have a release's file come
|
||||
// through a GitHub download mirror: its prefix goes before the file's
|
||||
// github.com URL (https://mirror.example/https://github.com/…). magpie
|
||||
// names no mirror of its own; one is used only when given (magpie update
|
||||
// --mirror, or Settings' UpdateMirror, which magpie update mirror sets).
|
||||
//
|
||||
// A mirror is a host magpie doesn't trust: the update feed, with each
|
||||
// file's SHA-256, is still asked at usemagpie.ai and never through the
|
||||
// mirror, the file is checked against that hash, and the macOS app must
|
||||
// still be signed by the team that signed the one installed. A mirror
|
||||
// that changes a byte gets its download refused.
|
||||
|
||||
type mirrorKey struct{}
|
||||
|
||||
// WithMirror has the downloads made under ctx go through the mirror
|
||||
// prefix; "off" goes through none, whatever Settings say, and "" leaves
|
||||
// ctx as it is.
|
||||
func WithMirror(ctx context.Context, prefix string) context.Context {
|
||||
if prefix = strings.TrimSpace(prefix); prefix == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, mirrorKey{}, prefix)
|
||||
}
|
||||
|
||||
// WithProxy has the update feed and the downloads asked under ctx go
|
||||
// through proxy (http://, https://, socks5://, socks5h://; host:port is
|
||||
// http; "direct" for none), over Settings' Proxy and the environment's.
|
||||
func WithProxy(ctx context.Context, proxy string) context.Context {
|
||||
return netproxy.With(ctx, proxy)
|
||||
}
|
||||
|
||||
// CheckMirror says what is wrong with a mirror prefix, nil when it is one.
|
||||
func CheckMirror(prefix string) error {
|
||||
u, err := url.Parse(strings.TrimSpace(prefix))
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||||
return fmt.Errorf("a mirror is an http(s) address put before the github.com URL, like https://mirror.example/, not %q", prefix)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckProxy says what is wrong with a proxy address, nil when it is one.
|
||||
func CheckProxy(proxy string) error {
|
||||
if proxy == "direct" {
|
||||
return nil
|
||||
}
|
||||
u, err := netproxy.Parse(proxy)
|
||||
if err != nil || u.Host == "" {
|
||||
return fmt.Errorf("a proxy is an address like http://127.0.0.1:7890 or socks5://127.0.0.1:1080, not %q", proxy)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http", "https", "socks5", "socks5h":
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("a proxy is http://, https://, socks5:// or socks5h://, not %q", proxy)
|
||||
}
|
||||
|
||||
// mirrorOf is the mirror downloads under ctx go through: WithMirror's,
|
||||
// else Settings', "" for none.
|
||||
func mirrorOf(ctx context.Context) string {
|
||||
m, _ := ctx.Value(mirrorKey{}).(string)
|
||||
if m == "" {
|
||||
m = strings.TrimSpace(settings.Load().UpdateMirror)
|
||||
}
|
||||
if m == "off" || m == "direct" {
|
||||
return ""
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// mirrored is raw fetched through the mirror prefix: only a file on
|
||||
// GitHub is; anything else (a test's server, a feed pointed elsewhere)
|
||||
// goes as it is.
|
||||
func mirrored(prefix, raw string) string {
|
||||
if prefix == "" {
|
||||
return raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || !githubHost(u.Hostname()) {
|
||||
return raw
|
||||
}
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
return prefix + raw
|
||||
}
|
||||
|
||||
func githubHost(h string) bool {
|
||||
h = strings.ToLower(h)
|
||||
return h == "github.com" || strings.HasSuffix(h, ".github.com") || strings.HasSuffix(h, ".githubusercontent.com")
|
||||
}
|
||||
|
||||
// Mirror is the mirror downloads under ctx go through, "" for none.
|
||||
func Mirror(ctx context.Context) string { return mirrorOf(ctx) }
|
||||
@@ -0,0 +1,169 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
)
|
||||
|
||||
func updateHome(t *testing.T) {
|
||||
t.Helper()
|
||||
h := t.TempDir()
|
||||
t.Setenv("HOME", h)
|
||||
t.Setenv("USERPROFILE", h)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
|
||||
for _, k := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
|
||||
// A release's file comes through a GitHub download mirror only when one is
|
||||
// given (magpie update --mirror, or Settings' UpdateMirror): its prefix
|
||||
// before the github.com URL. The feed, with the file's SHA-256, is never
|
||||
// asked through it, and a file the mirror changed is refused (akic404 on
|
||||
// Discord: install and update without a proxy).
|
||||
func TestUpdateThroughAMirror(t *testing.T) {
|
||||
updateHome(t)
|
||||
body := []byte("new magpie")
|
||||
sum := sha256.Sum256(body)
|
||||
asset := Asset{URL: "https://github.com/yetone/magpie-releases/releases/download/v9.9.9/magpie-cli-linux-amd64", SHA256: hex.EncodeToString(sum[:])}
|
||||
|
||||
var mu sync.Mutex
|
||||
var seen []string
|
||||
tamper := false
|
||||
mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
seen = append(seen, r.URL.Path)
|
||||
bad := tamper
|
||||
mu.Unlock()
|
||||
if bad {
|
||||
w.Write([]byte("not magpie"))
|
||||
return
|
||||
}
|
||||
w.Write(body)
|
||||
}))
|
||||
defer mirror.Close()
|
||||
feed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(Release{Version: "9.9.9", Assets: map[string]Asset{BinaryAsset(): asset}})
|
||||
}))
|
||||
defer feed.Close()
|
||||
t.Setenv("MAGPIE_UPDATE_FEED", feed.URL+"/api/latest")
|
||||
|
||||
// none unless given: no mirror is magpie's own
|
||||
if m := Mirror(context.Background()); m != "" {
|
||||
t.Fatalf("a mirror by default: %q", m)
|
||||
}
|
||||
ctx := WithMirror(context.Background(), mirror.URL)
|
||||
rel, err := Latest(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "magpie.new")
|
||||
if err := download(ctx, rel.Assets[BinaryAsset()], path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b, _ := os.ReadFile(path); string(b) != string(body) {
|
||||
t.Fatalf("downloaded %q", b)
|
||||
}
|
||||
mu.Lock()
|
||||
if want := []string{"/" + asset.URL}; !slices.Equal(seen, want) {
|
||||
t.Fatalf("the mirror was asked %v, want %v (the feed never)", seen, want)
|
||||
}
|
||||
seen, tamper = nil, true
|
||||
mu.Unlock()
|
||||
|
||||
// what the mirror changed doesn't match the feed's checksum
|
||||
os.Remove(path)
|
||||
err = download(ctx, asset, path)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match its checksum") || !strings.Contains(err.Error(), "mirror") {
|
||||
t.Fatalf("a tampered file: %v", err)
|
||||
}
|
||||
if _, serr := os.Stat(path); !os.IsNotExist(serr) {
|
||||
t.Fatal("the tampered file was kept")
|
||||
}
|
||||
|
||||
// Settings' mirror is the app's too; off turns it off for one run
|
||||
mu.Lock()
|
||||
tamper, seen = false, nil
|
||||
mu.Unlock()
|
||||
if err := settings.Save(settings.Settings{UpdateMirror: mirror.URL + "/"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := Mirror(context.Background()); m != mirror.URL+"/" {
|
||||
t.Fatalf("Settings' mirror: %q", m)
|
||||
}
|
||||
if err := download(context.Background(), asset, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := Mirror(WithMirror(context.Background(), "off")); m != "" {
|
||||
t.Fatalf("off: %q", m)
|
||||
}
|
||||
// a file not on GitHub goes as it is
|
||||
if got := mirrored(mirror.URL, feed.URL+"/x"); got != feed.URL+"/x" {
|
||||
t.Fatalf("a non-GitHub URL mirrored: %q", got)
|
||||
}
|
||||
if err := settings.Save(settings.Settings{UpdateMirror: "ftp://nope"}); err == nil {
|
||||
t.Fatal("an ftp mirror was saved")
|
||||
}
|
||||
}
|
||||
|
||||
// magpie update --proxy takes the feed and the download through the proxy
|
||||
// given, over Settings' and the environment's.
|
||||
func TestUpdateThroughAProxyGiven(t *testing.T) {
|
||||
updateHome(t)
|
||||
body := []byte("new magpie")
|
||||
sum := sha256.Sum256(body)
|
||||
var mu sync.Mutex
|
||||
var seen []string
|
||||
px := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
seen = append(seen, r.URL.String())
|
||||
mu.Unlock()
|
||||
if r.URL.Path == "/asset" {
|
||||
w.Write(body)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(Release{Version: "9.9.9", Assets: map[string]Asset{
|
||||
BinaryAsset(): {URL: "http://releases.magpie.invalid/asset", SHA256: hex.EncodeToString(sum[:])},
|
||||
}})
|
||||
}))
|
||||
defer px.Close()
|
||||
if err := settings.Save(settings.Settings{Proxy: "direct"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("MAGPIE_UPDATE_FEED", "http://feed.magpie.invalid/api/latest")
|
||||
ctx := WithProxy(context.Background(), px.URL)
|
||||
rel, err := Latest(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := download(ctx, rel.Assets[BinaryAsset()], filepath.Join(t.TempDir(), "magpie.new")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if want := []string{"http://feed.magpie.invalid/api/latest", "http://releases.magpie.invalid/asset"}; !slices.Equal(seen, want) {
|
||||
t.Fatalf("through the proxy: %v, want %v", seen, want)
|
||||
}
|
||||
for _, bad := range []string{"ftp://127.0.0.1:21", "socks4://127.0.0.1:1080"} {
|
||||
if CheckProxy(bad) == nil {
|
||||
t.Errorf("%s taken as a proxy", bad)
|
||||
}
|
||||
}
|
||||
for _, good := range []string{"127.0.0.1:7890", "http://127.0.0.1:7890", "socks5://127.0.0.1:1080", "socks5h://u:p@127.0.0.1:1080", "direct"} {
|
||||
if err := CheckProxy(good); err != nil {
|
||||
t.Errorf("%s: %v", good, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -591,7 +591,10 @@ func download(ctx context.Context, a Asset, path string) error {
|
||||
}
|
||||
|
||||
func fetch(ctx context.Context, a Asset, path string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", a.URL, nil)
|
||||
// through a mirror when one is given (mirror.go): the hash checked
|
||||
// below is still the feed's
|
||||
u := mirrored(mirrorOf(ctx), a.URL)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -601,6 +604,9 @@ func fetch(ctx context.Context, a Asset, path string) error {
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
if u != a.URL {
|
||||
return fmt.Errorf("download %s through the mirror %s: %s", filepath.Base(path), strings.TrimSuffix(u, a.URL), res.Status)
|
||||
}
|
||||
return fmt.Errorf("download %s: %s", filepath.Base(path), res.Status)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
@@ -623,6 +629,9 @@ func fetch(ctx context.Context, a Asset, path string) error {
|
||||
}
|
||||
if err == nil && !strings.EqualFold(hex.EncodeToString(h.Sum(nil)), a.SHA256) {
|
||||
err = fmt.Errorf("%s does not match its checksum", filepath.Base(path))
|
||||
if u != a.URL {
|
||||
err = fmt.Errorf("%s from the mirror %s does not match its checksum from %s; not installed", filepath.Base(path), strings.TrimSuffix(u, a.URL), Site)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
os.Remove(path)
|
||||
|
||||
@@ -96,7 +96,11 @@ const usage = `magpie — one place to pick every agent's model
|
||||
magpie quota [<provider>] [--json] what is left of every subscription, plan and key balance
|
||||
magpie sync refresh the model catalog and vendor model lists
|
||||
magpie agents list every supported agent
|
||||
magpie update [check] install the newest release (check: only say if there is one)
|
||||
magpie update [check] [--proxy <url>] [--mirror <prefix>]
|
||||
install the newest release (check: only say if there is one); --proxy: an
|
||||
http(s):// or socks5:// proxy for it; --mirror: a GitHub download mirror put
|
||||
before the github.com URL (none unless given; still checked against usemagpie.ai's SHA-256)
|
||||
magpie update mirror [<prefix>|off] the mirror every update, the app's own too, is downloaded through
|
||||
magpie update auto [on|off] [30m|1h|6h|24h] whether the app looks for updates by itself, and how often (6h)
|
||||
|
||||
agents: claude (cc), codex, gemini, opencode (oc), mimocode, pi, goose, cursor, zed, copilot, crush
|
||||
|
||||
+64
-5
@@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const installer = fileURLToPath(new URL("./public/install.sh", import.meta.url));
|
||||
|
||||
function install(t, { termux = true, arch = "aarch64", override = false, prefixOnly = false, missing = false, corrupt = false, desktop = false } = {}) {
|
||||
function install(t, { termux = true, arch = "aarch64", override = false, prefixOnly = false, missing = false, corrupt = false, desktop = false, github = false, args = [], env: extra = {}, tamper = "" } = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "magpie-install-"));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const tools = join(dir, "tools");
|
||||
@@ -26,7 +26,7 @@ function install(t, { termux = true, arch = "aarch64", override = false, prefixO
|
||||
// A Linux asset is present even when Android's is missing: never use it.
|
||||
for (const name of [file, `magpie-cli-linux-${cpu}`]) {
|
||||
if (missing && name === file) continue;
|
||||
assets[name] = { url: `https://download.invalid/${name}`, sha256: corrupt ? "0".repeat(64) : createHash("sha256").update(payload).digest("hex") };
|
||||
assets[name] = { url: github ? `https://github.com/yetone/magpie-releases/releases/download/vtest/${name}` : `https://download.invalid/${name}`, sha256: corrupt ? "0".repeat(64) : createHash("sha256").update(payload).digest("hex") };
|
||||
}
|
||||
const requests = join(dir, "requests");
|
||||
writeFileSync(join(tools, "uname"), `#!/bin/sh\ncase "$1" in -m) echo '${arch}';; -s) echo Linux;; esac\n`, { mode: 0o755 });
|
||||
@@ -36,8 +36,10 @@ const fs = require("node:fs");
|
||||
const args = process.argv.slice(2);
|
||||
const url = args.at(-1);
|
||||
fs.appendFileSync(process.env.INSTALL_REQUESTS, url + String.fromCharCode(10));
|
||||
fs.appendFileSync(process.env.INSTALL_ARGS, JSON.stringify(args) + String.fromCharCode(10));
|
||||
const tamper = process.env.INSTALL_TAMPER;
|
||||
if (url.endsWith("/api/latest")) process.stdout.write(process.env.INSTALL_FEED);
|
||||
else fs.writeFileSync(args[args.indexOf("-o") + 1], process.env.INSTALL_PAYLOAD);
|
||||
else fs.writeFileSync(args[args.indexOf("-o") + 1], tamper && url.startsWith(tamper) ? "changed by the mirror" : process.env.INSTALL_PAYLOAD);
|
||||
`, { mode: 0o755 });
|
||||
const env = {
|
||||
...process.env,
|
||||
@@ -52,9 +54,16 @@ else fs.writeFileSync(args[args.indexOf("-o") + 1], process.env.INSTALL_PAYLOAD)
|
||||
INSTALL_REQUESTS: requests,
|
||||
INSTALL_FEED: JSON.stringify({ version: "test", assets }),
|
||||
INSTALL_PAYLOAD: payload,
|
||||
INSTALL_ARGS: join(dir, "args"),
|
||||
INSTALL_TAMPER: tamper,
|
||||
MAGPIE_PROXY: "",
|
||||
MAGPIE_MIRROR: "",
|
||||
...extra,
|
||||
};
|
||||
const result = spawnSync("sh", [installer], { env, encoding: "utf8" });
|
||||
return { ...result, bin, file, payload, share: env.XDG_DATA_HOME, requests: readFileSync(requests, "utf8") };
|
||||
const result = spawnSync("sh", [installer, ...args], { env, encoding: "utf8" });
|
||||
const read = (p) => (existsSync(p) ? readFileSync(p, "utf8") : "");
|
||||
const calls = read(env.INSTALL_ARGS).trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
||||
return { ...result, bin, file, payload, share: env.XDG_DATA_HOME, requests: read(requests), calls };
|
||||
}
|
||||
|
||||
test("Termux downloads Android arm64 into PREFIX/bin even with WebKit installed", (t) => {
|
||||
@@ -104,3 +113,53 @@ test("ordinary Linux still selects its CLI or desktop build", (t) => {
|
||||
assert.equal(existsSync(join(r.share, "applications/magpie.desktop")), desktop);
|
||||
}
|
||||
});
|
||||
|
||||
// akic404 on Discord: installing without a way round the firewall. A proxy
|
||||
// and a GitHub mirror can be given; neither is used unless given.
|
||||
test("no proxy and no mirror unless given", (t) => {
|
||||
const r = install(t, { termux: false, github: true });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
for (const a of r.calls) assert.ok(!a.includes("--proxy"), JSON.stringify(a));
|
||||
assert.match(r.requests, /^https:\/\/github\.com\/yetone\/magpie-releases\//m);
|
||||
});
|
||||
|
||||
test("--proxy takes every download through it, as does MAGPIE_PROXY", (t) => {
|
||||
for (const [opts, want] of [
|
||||
[{ args: ["--proxy", "socks5h://127.0.0.1:1080"] }, "socks5h://127.0.0.1:1080"],
|
||||
[{ args: ["--proxy=http://127.0.0.1:7890"] }, "http://127.0.0.1:7890"],
|
||||
[{ env: { MAGPIE_PROXY: "http://10.0.0.1:3128" } }, "http://10.0.0.1:3128"],
|
||||
]) {
|
||||
const r = install(t, { termux: false, desktop: true, github: true, ...opts });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.calls.length, 3); // the feed, the file, the icon
|
||||
for (const a of r.calls) assert.equal(a[a.indexOf("--proxy") + 1], want, JSON.stringify(a));
|
||||
}
|
||||
const bad = install(t, { termux: false, args: ["--proxy", "ftp://x"] });
|
||||
assert.notEqual(bad.status, 0);
|
||||
assert.match(bad.stderr, /--proxy is an address/);
|
||||
assert.equal(bad.calls.length, 0);
|
||||
});
|
||||
|
||||
test("--mirror fetches the file through the mirror, the feed and its checksum from the site", (t) => {
|
||||
const r = install(t, { termux: false, github: true, args: ["--mirror", "https://gh.mirror.example"] });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const [feed, file] = r.requests.trim().split("\n");
|
||||
assert.equal(feed, "https://usemagpie.ai/api/latest");
|
||||
assert.equal(file, "https://gh.mirror.example/https://github.com/yetone/magpie-releases/releases/download/vtest/magpie-cli-linux-arm64");
|
||||
assert.equal(readFileSync(join(r.bin, "magpie"), "utf8"), r.payload);
|
||||
assert.match(r.stdout, /through https:\/\/gh\.mirror\.example\//);
|
||||
|
||||
const env = install(t, { termux: false, github: true, env: { MAGPIE_MIRROR: "https://gh.mirror.example/" } });
|
||||
assert.equal(env.status, 0, env.stderr);
|
||||
assert.match(env.requests, /^https:\/\/gh\.mirror\.example\/https:\/\/github\.com\//m);
|
||||
});
|
||||
|
||||
test("a file the mirror changed is refused and the installed magpie kept", (t) => {
|
||||
const r = install(t, { termux: false, github: true, args: ["--mirror", "https://gh.mirror.example/"], tamper: "https://gh.mirror.example/" });
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(r.stderr, /from https:\/\/gh\.mirror\.example\/ does not match its checksum from https:\/\/usemagpie\.ai; not installed/);
|
||||
assert.equal(readFileSync(join(r.bin, "magpie"), "utf8"), "old installation");
|
||||
const bad = install(t, { termux: false, args: ["--mirror", "gh.mirror.example"] });
|
||||
assert.notEqual(bad.status, 0);
|
||||
assert.match(bad.stderr, /--mirror is an http\(s\) address/);
|
||||
});
|
||||
@@ -256,6 +256,8 @@ magpie claude group/opus-anywhere <span class="c"># これを使う</span></p
|
||||
<p>実行中のセッションは開始時の設定を使い続けます。新しいセッションを始めてください。Codex の場合は Codex アプリも再起動してください。</p>
|
||||
<h3>エージェントの設定を手で編集すると、何か壊れますか?</h3>
|
||||
<p>いいえ。magpie は毎回ファイルを読み直し、自分が設定するキーだけに触れるので、他の設定やコメントはそのまま残ります。<span class="ui">Import…</span> で取り込んだプロバイダはコピーなので、その後エージェント側の設定を変えても再度コピーはされません。</p>
|
||||
<h3>GitHub に届かない環境で、インストールや更新をするには?</h3>
|
||||
<p>インストーラと <code>magpie update</code> にプロキシを渡します:<code>curl -fsSL https://usemagpie.ai/install.sh | sh -s -- --proxy http://127.0.0.1:7890</code>、または <code>magpie update --proxy socks5://127.0.0.1:1080</code>(http・https・socks5・socks5h に対応。<code>HTTPS_PROXY</code> と <code>ALL_PROXY</code> も使われます)。<code>--mirror <prefix></code> を付けると、指定した GitHub ダウンロードミラーから <code><prefix>https://github.com/…</code> としてリリースファイルを取得します。<code>magpie update mirror <prefix></code> でアプリ自身の更新を含め毎回使い、<code>magpie update mirror off</code> で解除します。指定しない限りミラーは使われず、ファイルの SHA-256 はミラーではなく常に usemagpie.ai から取得するので、ミラーが書き換えたファイルはインストールされません。アプリ自身の更新は <span class="ui">Settings</span> → <span class="ui">Proxy</span> を通ります。</p>
|
||||
|
||||
<p style="margin-top:48px">困ったときや、共有したい構成があるときは <a href="https://discord.gg/vGSnD3ZKQF">Discord</a> でどうぞ。ユーザーにすぐ使えるプロバイダを配りたいベンダーの方は <a href="/docs/ja/import">magpie に追加</a> をご覧ください。</p>
|
||||
</article>
|
||||
|
||||
@@ -256,6 +256,8 @@ magpie claude group/opus-anywhere <span class="c"># use it</span></pre>
|
||||
<p>A running session keeps what it started with. Start a new one; for Codex, restart the Codex app too.</p>
|
||||
<h3>Does editing the agent’s config by hand break anything?</h3>
|
||||
<p>No. magpie reads the files each time and touches only the keys it sets, so your other settings and comments stay. Providers brought over with <span class="ui">Import…</span> are copies: later changes in the agent’s settings aren’t copied again.</p>
|
||||
<h3>Installing or updating behind a firewall, or without access to GitHub?</h3>
|
||||
<p>Give the installer and <code>magpie update</code> a proxy: <code>curl -fsSL https://usemagpie.ai/install.sh | sh -s -- --proxy http://127.0.0.1:7890</code>, or <code>magpie update --proxy socks5://127.0.0.1:1080</code> (http, https, socks5 and socks5h; <code>HTTPS_PROXY</code> and <code>ALL_PROXY</code> are honoured too). <code>--mirror <prefix></code> fetches the release file from a GitHub download mirror you choose, as <code><prefix>https://github.com/…</code>; <code>magpie update mirror <prefix></code> keeps it for every update, the app’s too, and <code>magpie update mirror off</code> removes it. No mirror is used unless you give one, and the file’s SHA-256 still comes from usemagpie.ai, never from the mirror: a file the mirror changed is refused. The app’s own updates go through <span class="ui">Settings</span> → <span class="ui">Proxy</span>.</p>
|
||||
|
||||
<p style="margin-top:48px">Stuck, or have a setup worth sharing? Ask on <a href="https://discord.gg/vGSnD3ZKQF">Discord</a>. Vendors who want to hand their users a ready-made provider: see <a href="/docs/import">Add to magpie</a>.</p>
|
||||
</article>
|
||||
|
||||
@@ -256,6 +256,8 @@ magpie claude group/opus-anywhere <span class="c"># 使用它</span></pre>
|
||||
<p>已在运行的会话保持启动时的模型。开一个新会话;如果是 Codex,也请重启 Codex 应用。</p>
|
||||
<h3>手动改 Agent 的配置会冲突吗?</h3>
|
||||
<p>不会。magpie 每次都重新读取文件,只动它设置的那几个键,你的其他设置和注释都会保留。用 <span class="ui">导入…</span> 带进来的供应商是一份拷贝:之后在 Agent 里的改动不会再同步过来。</p>
|
||||
<h3>没有梯子,安装或更新连不上 GitHub 怎么办?</h3>
|
||||
<p>给安装脚本和 <code>magpie update</code> 加代理:<code>curl -fsSL https://usemagpie.ai/install.sh | sh -s -- --proxy http://127.0.0.1:7890</code>,或 <code>magpie update --proxy socks5://127.0.0.1:1080</code>(支持 http、https、socks5、socks5h;环境变量 <code>HTTPS_PROXY</code>、<code>ALL_PROXY</code> 同样生效)。<code>--mirror <前缀></code> 从你指定的 GitHub 下载镜像取发布文件,即 <code><前缀>https://github.com/…</code>;<code>magpie update mirror <前缀></code> 让之后每次更新(包括应用自己的更新)都用它,<code>magpie update mirror off</code> 取消。不指定就不用任何镜像;文件的 SHA-256 始终来自 usemagpie.ai,而不是镜像,镜像改过的文件会被拒绝安装。应用自己的更新走 <span class="ui">设置</span> → <span class="ui">代理</span>。</p>
|
||||
|
||||
<p style="margin-top:48px">遇到问题,或者有值得分享的配置?来 <a href="https://discord.gg/vGSnD3ZKQF">Discord</a> 聊。想给自己的用户一键添加供应商的厂商,请看 <a href="/docs/zh/import">Add to magpie</a>。</p>
|
||||
</article>
|
||||
|
||||
+58
-5
@@ -8,14 +8,58 @@
|
||||
# otherwise or with MAGPIE_CLI=1.
|
||||
# Termux: installs the Android terminal build into $PREFIX/bin.
|
||||
# Every download is checked against the release's SHA-256.
|
||||
#
|
||||
# Options (curl -fsSL https://usemagpie.ai/install.sh | sh -s -- --proxy …):
|
||||
# --proxy <url> a proxy for every download: http://, https://,
|
||||
# socks5:// or socks5h:// (MAGPIE_PROXY= does the same;
|
||||
# without either, curl's own https_proxy / HTTPS_PROXY /
|
||||
# ALL_PROXY are used as usual)
|
||||
# --mirror <prefix> a GitHub download mirror: the release file is fetched
|
||||
# from <prefix><its github.com URL> (MAGPIE_MIRROR= does
|
||||
# the same). None is used unless given. The release
|
||||
# list and its SHA-256 still come from usemagpie.ai,
|
||||
# never from the mirror, so a file the mirror changed is
|
||||
# refused.
|
||||
set -eu
|
||||
|
||||
site=https://usemagpie.ai
|
||||
bin="${MAGPIE_BIN_DIR:-$HOME/.local/bin}"
|
||||
proxy="${MAGPIE_PROXY:-}"
|
||||
mirror="${MAGPIE_MIRROR:-}"
|
||||
|
||||
say() { printf ' %s\n' "$*"; }
|
||||
die() { printf 'magpie: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--proxy) [ $# -ge 2 ] || die "--proxy needs a value"; proxy=$2; shift 2 ;;
|
||||
--proxy=*) proxy=${1#--proxy=}; shift ;;
|
||||
--mirror) [ $# -ge 2 ] || die "--mirror needs a value"; mirror=$2; shift 2 ;;
|
||||
--mirror=*) mirror=${1#--mirror=}; shift ;;
|
||||
-h|--help)
|
||||
printf '%s\n' "usage: curl -fsSL $site/install.sh | sh -s -- [--proxy <url>] [--mirror <prefix>]" \
|
||||
" --proxy <url> http://, https://, socks5:// or socks5h:// proxy for the downloads (or MAGPIE_PROXY=)" \
|
||||
" --mirror <prefix> GitHub download mirror put before the release's github.com URL (or MAGPIE_MIRROR=);" \
|
||||
" the SHA-256 is still checked against $site's"
|
||||
exit 0 ;;
|
||||
*) die "unknown option $1 (--proxy <url>, --mirror <prefix>)" ;;
|
||||
esac
|
||||
done
|
||||
case "$proxy" in
|
||||
''|http://?*|https://?*|socks5://?*|socks5h://?*) ;;
|
||||
*) die "--proxy is an address like http://127.0.0.1:7890 or socks5://127.0.0.1:1080, not $proxy" ;;
|
||||
esac
|
||||
case "$mirror" in
|
||||
'') ;;
|
||||
http://?*|https://?*) case "$mirror" in */) ;; *) mirror="$mirror/" ;; esac ;;
|
||||
*) die "--mirror is an http(s) address put before the github.com URL, like https://mirror.example/, not $mirror" ;;
|
||||
esac
|
||||
|
||||
# get: curl, through the proxy when one was given
|
||||
get() {
|
||||
if [ -n "$proxy" ]; then curl -fsSL --proxy "$proxy" "$@"; else curl -fsSL "$@"; fi
|
||||
}
|
||||
|
||||
case "$(uname -m)" in
|
||||
arm64|aarch64) arch=arm64 ;;
|
||||
x86_64|amd64) arch=amd64 ;;
|
||||
@@ -42,7 +86,8 @@ esac
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
feed=$(curl -fsSL "$site/api/latest") || die "could not reach $site"
|
||||
# the release list and its checksums: always from the site, never a mirror
|
||||
feed=$(get "$site/api/latest") || die "could not reach $site${proxy:+ through $proxy}"
|
||||
# The feed is one line of JSON; pull this file's url and hash out of it.
|
||||
entry=$(printf '%s' "$feed" | sed -n "s/.*\"$file\":{\([^}]*\)}.*/\1/p")
|
||||
url=$(printf '%s' "$entry" | sed -n 's/.*"url":"\([^"]*\)".*/\1/p')
|
||||
@@ -53,11 +98,19 @@ if [ "$os" = android ] && { [ -z "$url" ] || [ -z "$sum" ]; }; then
|
||||
fi
|
||||
[ -n "$url" ] && [ -n "$sum" ] || die "the newest release has no $file"
|
||||
|
||||
say "downloading magpie $version ($file)"
|
||||
curl -fsSL -o "$tmp/$file" "$url"
|
||||
from=$url
|
||||
case "$url" in
|
||||
https://github.com/*) [ -z "$mirror" ] || from="$mirror$url" ;;
|
||||
esac
|
||||
if [ "$from" != "$url" ]; then say "downloading magpie $version ($file) through $mirror"
|
||||
else say "downloading magpie $version ($file)"; fi
|
||||
get -o "$tmp/$file" "$from" || die "could not download $from"
|
||||
if command -v sha256sum >/dev/null; then got=$(sha256sum "$tmp/$file" | cut -d' ' -f1)
|
||||
else got=$(shasum -a 256 "$tmp/$file" | cut -d' ' -f1); fi
|
||||
[ "$got" = "$sum" ] || die "$file does not match its checksum"
|
||||
if [ "$got" != "$sum" ]; then
|
||||
[ "$from" = "$url" ] || die "$file from $mirror does not match its checksum from $site; not installed"
|
||||
die "$file does not match its checksum"
|
||||
fi
|
||||
|
||||
mkdir -p "$bin"
|
||||
if [ "$os" = darwin ]; then
|
||||
@@ -73,7 +126,7 @@ else
|
||||
if [ "$file" = "magpie-linux-$arch" ]; then
|
||||
share="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
mkdir -p "$share/applications" "$share/icons/hicolor/256x256/apps"
|
||||
curl -fsSL -o "$share/icons/hicolor/256x256/apps/magpie.png" "$site/img/icon-256.png" || true
|
||||
get -o "$share/icons/hicolor/256x256/apps/magpie.png" "$site/img/icon-256.png" || true
|
||||
cat > "$share/applications/magpie.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
|
||||
+88
-4
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
@@ -50,14 +51,89 @@ func updateAutoCmd(args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateCmd is `magpie update [check]`: the app replaces its bundle, the
|
||||
// terminal build its binary.
|
||||
// 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
|
||||
@@ -81,7 +157,7 @@ func updateCmd(args []string) error {
|
||||
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() + " …"))
|
||||
fmt.Println(muted.Render(" downloading " + update.AppAsset() + via(ctx) + " …"))
|
||||
staged, err := update.Stage(ctx, rel, app)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -106,10 +182,18 @@ func updateCmd(args []string) error {
|
||||
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() + " …"))
|
||||
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 ""
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
)
|
||||
|
||||
// magpie update takes --proxy and --mirror, as --flag value or
|
||||
// --flag=value, before or after check; magpie update mirror keeps one for
|
||||
// every update, the app's too, and off clears it.
|
||||
func TestUpdateFlags(t *testing.T) {
|
||||
rest, proxy, mirror, err := updateFlags([]string{"update", "--proxy", "socks5://127.0.0.1:1080", "check", "--mirror=https://mirror.example/"})
|
||||
if err != nil || !slices.Equal(rest, []string{"update", "check"}) || proxy != "socks5://127.0.0.1:1080" || mirror != "https://mirror.example/" {
|
||||
t.Fatalf("%v %q %q %v", rest, proxy, mirror, err)
|
||||
}
|
||||
for _, bad := range [][]string{
|
||||
{"update", "--proxy"},
|
||||
{"update", "--proxy", "ftp://x:1"},
|
||||
{"update", "--mirror", "mirror.example"},
|
||||
} {
|
||||
if _, _, _, err := updateFlags(bad); err == nil {
|
||||
t.Errorf("%v taken", bad)
|
||||
}
|
||||
}
|
||||
if err := updateCmd([]string{"update", "chek"}); err == nil {
|
||||
t.Error("magpie update chek ran an update")
|
||||
}
|
||||
|
||||
groupsHome(t)
|
||||
if err := updateCmd([]string{"update", "mirror", "https://mirror.example/"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := settings.Load().UpdateMirror; got != "https://mirror.example/" {
|
||||
t.Fatalf("saved %q", got)
|
||||
}
|
||||
if err := updateCmd([]string{"update", "mirror", "nope"}); err == nil || settings.Load().UpdateMirror != "https://mirror.example/" {
|
||||
t.Fatalf("a bad mirror: %v, kept %q", err, settings.Load().UpdateMirror)
|
||||
}
|
||||
if err := updateCmd([]string{"update", "mirror", "off"}); err != nil || settings.Load().UpdateMirror != "" {
|
||||
t.Fatalf("off: %v, kept %q", err, settings.Load().UpdateMirror)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user