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:
yetone committed 2026-10-04 00:19:47 +08:00
1 parent 38a0c1f053
commit bc141ad834
18 files changed
+688 -18

No files matched your search

+15
View File
@@ -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
+2
View File
@@ -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))
+1
View File
@@ -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 {
+59
View File
@@ -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)
}
}
}
+49 -1
View File
@@ -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 {
+1 -1
View File
@@ -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"}}
+12
View File
@@ -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)
}
+105
View File
@@ -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) }
+169
View File
@@ -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)
}
}
}
+10 -1
View File
@@ -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)
+5 -1
View File
@@ -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
View File
@@ -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/);
});
+2
View File
@@ -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 &lt;prefix&gt;</code> を付けると、指定した GitHub ダウンロードミラーから <code>&lt;prefix&gt;https://github.com/…</code> としてリリースファイルを取得します。<code>magpie update mirror &lt;prefix&gt;</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>
+2
View File
@@ -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 &lt;prefix&gt;</code> fetches the release file from a GitHub download mirror you choose, as <code>&lt;prefix&gt;https://github.com/…</code>; <code>magpie update mirror &lt;prefix&gt;</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>
+2
View File
@@ -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 &lt;前缀&gt;</code> 从你指定的 GitHub 下载镜像取发布文件,即 <code>&lt;前缀&gt;https://github.com/…</code>;<code>magpie update mirror &lt;前缀&gt;</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
View File
@@ -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
View File
@@ -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 ""
}
+44
View File
@@ -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)
}
}