plugins: Check for updates asks npm now for each installed plugin's newest version, and each row says what it found (the owner: 插件的检查更新按钮还是得做) — the community's plugins update by themselves a little after magpie starts and every 6 hours, and the page knew of anyone else's new version only from what npm said within the hour (Info's cache), so a reader who heard a plugin had a new version had no way to have magpie look. The foot of Installed (beside Update all) now has Check for updates, shown when a plugin is npm's (a folder's or a git one's has no versions there; a git one keeps its own Update, which fetches it again): it says Checking… with a spinner while POST /api/plugins/check asks npm, a few at a time, past the hour's cache (plugin.CheckNow, through npmAsk, which is npmInfo telling why npm said nothing), and nothing is installed. Each row then says it: "v0.0.9 out" titled "v0.0.7 installed, v0.0.9 on npm" with its Update (the existing upgrade path, Upgrade → Add), "Up to date", or an amber "Couldn't check" chip with a dot, titled why — npm not reached (check your connection or proxy), npm turning requests away (429, try again in a few minutes), npm answering with an error, or npm not having the package (fetchJSON's answers other than 200/404 are now a statusError, so a 429 is told apart). The status line says how many have updates, or that every plugin is up to date, and how many couldn't be checked and why; npm not reached at all is "Couldn't check for updates: npm couldn't be reached — check your connection or proxy". A community plugin's Update is titled that magpie would update it by itself within a few hours and Update does it now. What npm said is kept as Info keeps it, so the rows and the market read it, and an update found for the reader (someone else's, or pinned) waits as the background look's do, putting the dot on Plugins; one npm didn't answer for keeps what waited before. Every new string has its Chinese; the click never scrolls the page. TestCheckNow (an httptest npm registry: the community's and someone else's updates found past an hour-old cache saying otherwise, up to date, 404 missing, 429 limited, 500 registry, git and folder not asked, nothing installed, the cache and the waiting list updated) and TestCheckNowOffline (npm not reached: offline, what waited still waits) are new, as is TestPluginsCheckRoute; plugin-check-updates.test.cjs (WebKit and Chromium, en and zh: Checking… while npm is asked, the rows' out / up to date / couldn't check chips and titles, the status line, Update after the check, offline said plainly, the view's scroll unmoved) fails without the change and passes with it. plugin-updates, plugin-market, plugin-progressive, plugin-git, plugin-local and click-scroll pass in WebKit and Chromium. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-02 21:58:22 +08:00
1 parent 14b9928d33
commit 94bb3e2ca6
9 files changed
+601 -7

No files matched your search

+14
View File
@@ -2399,6 +2399,20 @@ const I18N = {
"Update all ({n})": "全部更新({n})",
"Plugins updated": "插件已更新",
"v{v} out": "v{v} 可用",
"Ask npm now for each plugin's newest version": "立即向 npm 查询每个插件的最新版本",
"Last checked {time}": "上次检查:{time}",
"1 plugin has an update": "1 个插件有更新",
"{n} plugins have updates": "{n} 个插件有更新",
"Every plugin is up to date": "所有插件都是最新的",
"Couldn't check for updates: {error}": "无法检查更新:{error}",
"Couldn't check": "无法检查",
"npm couldn't be reached — check your connection or proxy": "无法连接 npm,请检查网络或代理",
"npm is turning requests away for now (too many); try again in a few minutes": "npm 暂时拒绝请求(请求过多),请几分钟后再试",
"npm doesn't have it": "npm 上没有这个包",
"npm answered with an error: {error}": "npm 返回错误:{error}",
"v{have} installed, v{v} on npm": "已安装 v{have},npm 上有 v{v}",
"v{v} is the newest on npm": "v{v} 是 npm 上的最新版本",
"magpie updates it by itself within a few hours; Update does it now": "magpie 会在几小时内自动更新它;点击“更新”立即更新",
"Auto-updated": "已自动更新",
"magpie updated it from v{from} to v{to} on {date}": "magpie 已于 {date} 自动将它从 v{from} 更新到 v{to}",
"An update for {name} is out": "{name} 有新版本",
+4
View File
@@ -70,6 +70,10 @@
.pm-chip.moved { gap: 4px; background: var(--accent-soft); color: var(--accent); }
.pm-chip.moved .dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.pm-row .sub.ask { color: var(--fg); }
.pm-chip.warn { gap: 4px; background: var(--amber-soft); color: var(--amber); }
.pm-chip.warn .dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.pm-foot .pm-check { display: inline-flex; align-items: center; gap: 5px; }
.pm-foot .pm-check .spin { width: 9px; height: 9px; border-radius: 50%; border: 1.5px solid currentColor; border-right-color: transparent; animation: spin .7s linear infinite; }
.pm-chip.soft { background: none; box-shadow: inset 0 0 0 1px var(--line); color: var(--muted); }
/* a plugin's one button */
+81 -4
View File
@@ -23,6 +23,8 @@
let searchTimer = 0;
const busy = new Map(); // package → "add" | "remove" | "upgrade" | "off"
let asking = null; // { pkg, op }: a remove or switch-off waiting on the user's yes
let checking = false; // Check for updates: npm being asked now
let checked = null; // ...and what it said: { at, by: spec → check }
let shown = "";
const SEARCH = "M7 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M10 10l3 3";
@@ -170,6 +172,55 @@
status(e.message, "err");
}
}
// Check for updates: npm asked now, not what it said within the hour,
// for each installed plugin's newest version. Each row then says so (an
// update, up to date, or why it couldn't be checked), and its Update, or
// Update all, installs it as before. Nothing is installed by the check.
async function checkUpdates() {
if (checking || busy.size) return;
checking = true;
draw();
try {
const r = await api("plugins/check", {});
mine = r.state || mine;
const by = {};
for (const c of r.plugins || []) {
by[c.spec] = c;
// npm's answer now is the one the rows read
if (c.latest) npm[c.package] = { ...(npm[c.package] || {}), version: c.latest };
}
checked = { at: r.at, by };
merge();
const asked = (r.plugins || []).filter((c) => c.status === "update" || c.status === "current" || c.status === "unknown");
const ups = asked.filter((c) => c.status === "update" && !c.off).length;
const unknown = asked.filter((c) => c.status === "unknown");
if (asked.length && unknown.length === asked.length) {
status(t("Couldn't check for updates: {error}", { error: whyText(unknown[0]) }), "err", 8000);
} else {
let msg = ups ? (ups === 1 ? t("1 plugin has an update") : t("{n} plugins have updates", { n: ups })) : t("Every plugin is up to date");
if (unknown.length) {
msg += " · " + t("{n} couldn't be checked: {error}", { n: unknown.length, error: whyText(unknown[0]) });
status(msg, "warn", 8000);
} else status(msg, "ok");
}
} catch (e) {
status(t("Couldn't check for updates: {error}", { error: e.message }), "err", 8000);
}
checking = false;
draw();
window.renderPluginDot?.();
}
// why npm said nothing of a plugin, in the page's words
function whyText(c) {
switch (c.why) {
case "offline": return t("npm couldn't be reached — check your connection or proxy");
case "limited": return t("npm is turning requests away for now (too many); try again in a few minutes");
case "missing": return t("npm doesn't have it");
default: return t("npm answered with an error: {error}", { error: c.error || "" });
}
}
const checkedAt = () => checked ? new Date(checked.at).toLocaleTimeString(document.documentElement.lang || undefined, { hour: "2-digit", minute: "2-digit" }) : "";
// the built-ins with accounts this plugin could run in their place
const movableOf = (pkg) => (mine?.movable || []).filter((c) => c.package === pkg);
// move: a built-in's accounts onto its plugin, installing it if need be,
@@ -554,9 +605,19 @@
const foot = el("div", "pm-foot");
const outdated = es.filter((e) => e.latest && e.version && newer(e.latest, e.version));
foot.append(el("span", "", mine.bun ? t("Plugins run on Bun {v}", { v: mine.bunVersion }) : ""), el("span", "grow"));
// only npm has versions to ask for: a folder's or a git one's has none
if (es.some((e) => onNPM(e.spec))) {
const c = el("button", "text pm-check" + (checking ? " busy" : ""));
if (checking) c.append(el("span", "spin"));
c.append(el("span", "", checking ? t("Checking…") : t("Check for updates")));
c.title = t("Ask npm now for each plugin's newest version") + (checked ? "\n" + t("Last checked {time}", { time: checkedAt() }) : "");
c.disabled = checking || busy.size > 0;
c.onclick = () => checkUpdates();
foot.append(c);
}
if (outdated.length) {
const up = el("button", "text", busy.has("*") ? t("Updating…") : t("Update all ({n})", { n: outdated.length }));
up.disabled = busy.size > 0;
up.disabled = busy.size > 0 || checking;
up.onclick = () => act("*", "update", {}, () => status(t("Plugins updated"), "ok"));
foot.append(up);
}
@@ -578,12 +639,26 @@
const nm = el("div", "name");
nm.append(el("span", "", shownName(e)));
if (e.version) nm.append(el("span", "pm-ver", "v" + e.version));
if (e.latest && e.version && newer(e.latest, e.version)) nm.append(el("span", "pm-chip up", t("v{v} out", { v: e.latest })));
else if (e.autoUpdated && e.autoUpdated.to === e.version) {
const ck = checked?.by[e.spec];
if (e.latest && e.version && newer(e.latest, e.version)) {
const c = el("span", "pm-chip up", t("v{v} out", { v: e.latest }));
c.title = t("v{have} installed, v{v} on npm", { have: e.version, v: e.latest });
nm.append(c);
} else if (ck?.status === "unknown") {
// asked just now, and npm didn't say: the row says why
const c = el("span", "pm-chip warn");
c.append(el("span", "dot"), el("span", "", t("Couldn't check")));
c.title = whyText(ck);
nm.append(c);
} else if (e.autoUpdated && e.autoUpdated.to === e.version) {
// magpie updated it by itself lately: the row says so, quietly
const c = el("span", "pm-chip soft", t("Auto-updated"));
c.title = t("magpie updated it from v{from} to v{to} on {date}", { from: e.autoUpdated.from, to: e.autoUpdated.to, date: new Date(e.autoUpdated.at).toLocaleDateString(document.documentElement.lang || undefined, { month: "short", day: "numeric" }) });
nm.append(c);
} else if ((ck?.status === "current" || ck?.status === "update") && e.version) {
const c = el("span", "pm-chip soft", t("Up to date"));
c.title = t("v{v} is the newest on npm", { v: e.version }) + "\n" + t("Last checked {time}", { time: checkedAt() });
nm.append(c);
}
// the built-in subscriptions moved onto it: taking it away moves them
// back, so the row says it carries them
@@ -626,7 +701,9 @@
const b = busy.get(pkg) || busy.get(e.spec);
if (e.latest && e.version && newer(e.latest, e.version) && !e.off) {
const up = el("button", "text", b === "upgrade" ? t("Updating…") : t("Update"));
up.disabled = busy.size > 0;
up.disabled = busy.size > 0 || checking;
// the community's would come by itself: Update brings it now
if (pkg.startsWith("@magpie-community/") && !/@(?!latest$)[^@/]+$/.test(e.spec.slice(pkg.length))) up.title = t("magpie updates it by itself within a few hours; Update does it now");
up.onclick = () => act(pkg, "upgrade", { spec: e.spec }, () => status(t("{name} updated to v{v}", { name: l?.name || pkg, v: e.latest }), "ok"));
val.append(up);
} else if (isGit(e.spec) && !e.off) {
+8
View File
@@ -274,6 +274,14 @@ func pluginRoutes(mux *http.ServeMux, w Windows) {
}
writeJSON(rw, map[string]string{"dir": dir})
})
// Check for updates: npm asked now for each plugin's newest version,
// with the list as it then stands; nothing installed
mux.HandleFunc("POST /api/plugins/check", func(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
c := plugin.CheckNow(ctx)
writeJSON(rw, map[string]any{"at": c.At, "plugins": c.Plugins, "state": pluginsState(ctx, w)})
})
// add, remove, update, turn on or off: each answers with the list
mux.HandleFunc("POST /api/plugins/{op}", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
+38
View File
@@ -0,0 +1,38 @@
package gui
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// Check for updates is a route of its own, not one of the plugin ops: it
// answers with what npm said of each plugin (none here, so npm is never
// asked) and the list as it then stands.
func TestPluginsCheckRoute(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("MAGPIE_PLUGIN_MARKET", "off")
mux := http.NewServeMux()
pluginRoutes(mux, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("POST", "/api/plugins/check", nil))
if rec.Code != 200 {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
var got struct {
At string
Plugins []any
State *pluginsJSON
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.At == "" || got.Plugins == nil || got.State == nil || got.State.Plugins == nil {
t.Fatalf("answered %s", rec.Body)
}
}
@@ -0,0 +1,162 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Check for updates on Plugins (the owner: 插件的检查更新按钮还是得做): the
// button asks npm now, says it is checking, and each row then says what it
// found — an update with the version installed and the one on npm, up to
// date, or why it couldn't be checked; Update installs the one found. npm
// not reached says so. The click never scrolls the page. English and
// Chinese; the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const COPILOT = "opencode-copilot-auth", ZED = "@magpie-community/opencode-zed-auth", KIRO = "@magpie-community/opencode-kiro-auth";
function server(lang, calls) {
// what npm said within the hour: nothing newer than what's installed
const installed = [
{ spec: COPILOT, providers: ["GitHub Copilot"], version: "0.0.7", latest: "0.0.7", moved: [] },
{ spec: ZED, providers: ["Zed"], version: "0.1.4", latest: "0.1.4", moved: [] },
{ spec: KIRO, providers: ["Kiro"], version: "0.1.2", latest: "0.1.2", moved: [] },
{ spec: "github:me/opencode-x-auth", package: "opencode-x-auth", providers: ["X"], version: "1.0.0", moved: [] },
];
for (let i = 0; i < 14; i++) installed.push({ spec: "opencode-filler-" + String(i).padStart(2, "0"), providers: ["F"], version: "1.0.0", latest: "1.0.0", moved: [] });
let offline = false, gate = null;
const state = () => ({ bun: true, bunVersion: "1.3.0", plugins: installed, movable: [] });
const fn = async (route) => {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } });
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true }, plugins: [] });
if (url.pathname === "/api/groups") return json({ groups: [], pools: [] });
if (url.pathname === "/api/gateway/trace") return json({ routes: [] });
if (url.pathname === "/api/plugins/updates") return json({ checked: "2026-10-01T00:00:00Z", waiting: installed.filter((e) => e.spec === COPILOT && e.latest !== e.version).map((e) => ({ spec: e.spec, package: e.spec, version: e.version, latest: e.latest })), updated: [] });
if (url.pathname === "/api/plugins/npm") return json({ npm: {} });
if (url.pathname === "/api/plugins") return json(state());
if (url.pathname === "/api/plugins/listings") return json({ listings: [] });
if (url.pathname === "/api/plugins/check") {
calls.push("check");
if (gate) await gate;
const at = "2026-10-02T09:30:00Z";
if (offline) return json({ at, state: state(), plugins: installed.filter((e) => !e.spec.startsWith("github:")).map((e) => ({ spec: e.spec, package: e.spec, version: e.version, status: "unknown", why: "offline", error: "dial tcp: no route to host", auto: false, off: false })) });
installed[0].latest = "0.0.9";
const plugins = installed.map((e) => e.spec.startsWith("github:") ? { spec: e.spec, package: e.package, version: e.version, status: "git", auto: false, off: false }
: e.spec === KIRO ? { spec: e.spec, package: e.spec, version: e.version, status: "unknown", why: "limited", error: "429 Too Many Requests", auto: true, off: false }
: { spec: e.spec, package: e.spec, version: e.version, latest: e.latest, status: e.latest === e.version ? "current" : "update", auto: e.spec === ZED, off: false });
return json({ at, state: state(), plugins });
}
if (url.pathname === "/api/plugins/upgrade") {
const body = route.request().postDataJSON();
calls.push("upgrade " + body.spec);
installed[0].version = "0.0.9";
return json(state());
}
if (url.pathname.startsWith("/api/")) return json({});
if (!/^\/[\w./-]*$/.test(url.pathname) || url.host !== "magpie.test") return route.fulfill({ status: 404, body: "" });
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
};
fn.offline = () => { offline = true; };
fn.hold = () => { let open; gate = new Promise((r) => { open = r; }); return () => { gate = null; open(); }; };
return fn;
}
const L = {
en: {
installed: "Installed", check: "Check for updates", checking: "Checking…", update: "Update", out: "v0.0.9 out", outTitle: "v0.0.7 installed, v0.0.9 on npm",
current: "Up to date", couldnt: "Couldn't check", limited: /too many/, found: /1 plugin has an update · 1 couldn't be checked: npm is turning requests away/,
updated: "opencode-copilot-auth updated to v0.0.9", offline: /^Couldn't check for updates: npm couldn't be reached — check your connection or proxy$/,
},
zh: {
installed: "已安装", check: "检查更新", checking: "检查中…", update: "更新", out: "v0.0.9 可用", outTitle: "已安装 v0.0.7,npm 上有 v0.0.9",
current: "已是最新", couldnt: "无法检查", limited: /请求过多/, found: /1 个插件有更新 · 1 个无法检查:npm 暂时拒绝请求/,
updated: /opencode-copilot-auth/, offline: /^无法检查更新:无法连接 npm,请检查网络或代理$/,
},
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
test(engine + ": plugins check for updates", async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
for (const lang of ["en", "zh"]) {
await t.test(lang, async () => {
const w = L[lang];
const calls = [];
const page = await (await browser.newContext({ viewport: { width: 980, height: 560 } })).newPage();
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
const srv = server(lang, calls);
await page.route("**/*", srv);
await page.goto("http://magpie.test/?view=plugins");
const view = page.locator("#view-plugins");
await view.locator(".lib-tabs .opt", { hasText: w.installed }).click();
const row = (spec) => view.locator(".pm-row").filter({ has: page.locator(".name span", { hasText: new RegExp("^" + spec.replace(/[/@-]/g, "\\$&") + "$") }) });
await row(COPILOT).waitFor();
// nothing newer known yet: no Update offered
assert.equal(await row(COPILOT).getByRole("button", { name: w.update, exact: true }).count(), 0);
// the reader scrolls down to the button at the list's foot
const check = view.locator(".pm-foot .pm-check");
const box = await view.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + 60);
for (let i = 0; i < 80; i++) { await page.mouse.wheel(0, 60); await page.waitForTimeout(15); }
await page.waitForTimeout(200);
const top = await view.evaluate((v) => v.scrollTop);
assert(top > 0, "the view must have scrolled");
assert.equal((await check.textContent()).trim(), w.check);
const open = srv.hold();
await check.click();
await page.waitForFunction((s) => document.querySelector("#view-plugins .pm-foot .pm-check")?.textContent.trim() === s, w.checking);
assert(await check.isDisabled());
open();
await page.waitForFunction((s) => document.querySelector("#view-plugins .pm-foot .pm-check")?.textContent.trim() === s, w.check);
assert.match(await page.locator("#status").textContent(), w.found);
assert.equal(await view.evaluate((v) => v.scrollTop), top, "the check must not scroll the page");
// each row says what npm said now
const out = row(COPILOT).locator(".pm-chip.up");
assert.equal(await out.textContent(), w.out);
assert.equal(await out.getAttribute("title"), w.outTitle);
assert.equal(await row(ZED).locator(".pm-chip.soft").textContent(), w.current);
const warn = row(KIRO).locator(".pm-chip.warn");
assert.equal(await warn.textContent(), w.couldnt);
assert.match(await warn.getAttribute("title"), w.limited);
assert.equal(await warn.locator(".dot").count(), 1);
// a git one isn't npm's to check: nothing said of it
assert.equal(await row("opencode-x-auth").locator(".pm-chip.soft, .pm-chip.warn").count(), 0);
// the dot on Plugins: someone else's update waits
await page.waitForFunction(() => document.querySelector('#nav button[data-view="plugins"]')?.classList.contains("has-dot"));
// Update installs the one found, as before: the reader scrolls back up to it
for (let i = 0; i < 80; i++) { await page.mouse.wheel(0, -60); await page.waitForTimeout(15); }
await page.waitForTimeout(200);
const top2 = await view.evaluate((v) => v.scrollTop);
await row(COPILOT).getByRole("button", { name: w.update, exact: true }).click();
await row(COPILOT).locator(".pm-chip.soft", { hasText: w.current }).waitFor();
assert.deepEqual(calls, ["check", "upgrade " + COPILOT]);
assert.match(await page.locator("#status").textContent(), w.updated instanceof RegExp ? w.updated : new RegExp(w.updated.replace(/\./g, "\\.")));
assert.equal(await view.evaluate((v) => v.scrollTop), top2, "updating must not scroll the page");
// npm not reached: said so, plainly
for (let i = 0; i < 80; i++) { await page.mouse.wheel(0, 60); await page.waitForTimeout(15); }
await page.waitForTimeout(200);
const top3 = await view.evaluate((v) => v.scrollTop);
srv.offline();
await check.click();
await page.waitForFunction(() => /npm/.test(document.querySelector("#status")?.textContent || ""));
assert.match(await page.locator("#status").textContent(), w.offline);
assert(await page.locator("#status").evaluate((s) => s.classList.contains("err")));
assert.equal(await view.evaluate((v) => v.scrollTop), top3);
assert.deepEqual(errors, []);
});
}
});
}
+22 -3
View File
@@ -207,13 +207,22 @@ func fetchJSON(ctx context.Context, u string, limit int64) ([]byte, error) {
return nil, errNotFound
}
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: %s", u, res.Status)
return nil, &statusError{URL: u, Code: res.StatusCode, Status: res.Status}
}
return io.ReadAll(io.LimitReader(res.Body, limit))
}
var errNotFound = errors.New("not found")
// statusError is a server's answer other than 200 or 404.
type statusError struct {
URL string
Code int
Status string
}
func (e *statusError) Error() string { return e.URL + ": " + e.Status }
// npmPath is a package's name as the registry's paths take it.
func npmPath(name string) string { return strings.Replace(url.PathEscape(name), "%40", "@", 1) }
@@ -333,6 +342,16 @@ func repoURL(v any) string {
}
func npmInfo(ctx context.Context, name string) (NPM, bool) {
info, err := npmAsk(ctx, name)
if err != nil {
return NPM{}, errors.Is(err, errNotFound) // not on npm: known, and kept
}
return info, true
}
// npmAsk is what npm says of the package now, or why it said nothing:
// errNotFound when it has no such package.
func npmAsk(ctx context.Context, name string) (NPM, error) {
var info NPM
var wg sync.WaitGroup
var latestErr error
@@ -372,9 +391,9 @@ func npmInfo(ctx context.Context, name string) (NPM, bool) {
wg.Wait()
info.Weekly = weekly
if latestErr != nil {
return NPM{}, errors.Is(latestErr, errNotFound) // not on npm: known, and kept
return NPM{}, latestErr
}
return info, true
return info, nil
}
// Hit is a package npm's search found.
+137
View File
@@ -11,7 +11,11 @@ package plugin
import (
"context"
"encoding/json"
"errors"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
@@ -218,3 +222,136 @@ func KeepUpdated(ctx context.Context) {
t.Reset(updateEvery)
}
}
// VersionCheck is what asking npm now found of one installed plugin.
type VersionCheck struct {
Spec string `json:"spec"`
Package string `json:"package"`
Version string `json:"version"` // installed
Latest string `json:"latest,omitempty"` // npm's newest, when it answered
// Status is "update" (npm has a newer version), "current", "unknown"
// (npm didn't say: Why and Error say why), or "git" or "folder" for
// one npm has no versions of
Status string `json:"status"`
// Why is why npm didn't say: "offline" (not reached), "limited" (too
// many requests), "registry" (it answered with an error) or "missing"
// (it has no such package)
Why string `json:"why,omitempty"`
Error string `json:"error,omitempty"`
// Auto is whether magpie updates it by itself (the community's,
// unpinned, switched on), so an update found would come anyway
Auto bool `json:"auto"`
Off bool `json:"off"`
}
// VersionChecks is what a check asked now found.
type VersionChecks struct {
At time.Time `json:"at"`
Plugins []VersionCheck `json:"plugins"`
}
// checkEach is how long npm is waited for, for each package a check asks
var checkEach = 15 * time.Second
// CheckNow asks npm now, not what it said within the hour, for the newest
// version of each installed plugin, for the reader's Check for updates.
// Nothing is installed: what it finds is updated as any update is, with
// Upgrade. What npm says is kept, as Info keeps it, and the updates found
// for the reader (someone else's, or pinned) wait for them as the
// background look's do, putting the dot on Plugins.
func CheckNow(ctx context.Context) VersionChecks {
es := Load().Plugins
out := make([]VersionCheck, len(es))
var wg sync.WaitGroup
sem := make(chan struct{}, npmAtOnce)
for i, e := range es {
c := VersionCheck{Spec: e.Spec, Package: Name(e.Spec), Version: Installed(e.Spec), Off: e.Off}
c.Auto = Official(c.Package) && !Pinned(e.Spec) && !e.Off
switch {
case IsPath(e.Spec):
c.Status = "folder"
case IsGit(e.Spec):
c.Status = "git"
}
out[i] = c
if c.Status != "" {
continue
}
wg.Add(1)
go func(c *VersionCheck) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
cctx, cancel := context.WithTimeout(ctx, checkEach)
info, err := npmAsk(cctx, c.Package)
cancel()
if err == nil || errors.Is(err, errNotFound) {
npmMu.Lock()
npmCached()[c.Package] = npmEntry{info, time.Now()}
saveNPM()
npmMu.Unlock()
}
if err == nil && info.Version == "" {
err = errors.New("npm named no version")
}
if err != nil {
c.Status, c.Why, c.Error = "unknown", whyNot(err), err.Error()
return
}
c.Latest, c.Status = info.Version, "current"
if c.Version != "" && update.Newer(info.Version, c.Version) {
c.Status = "update"
}
}(&out[i])
}
wg.Wait()
now := time.Now().UTC().Truncate(time.Second)
// the updates found wait for the reader, as the background look's do;
// one npm didn't answer for waits as it did
updatesMu.Lock()
u := readUpdates()
asked := map[string]bool{}
var waiting []Waiting
for _, c := range out {
if c.Status == "unknown" || c.Status == "git" || c.Status == "folder" {
continue
}
asked[c.Package] = true
if c.Status == "update" && !c.Off && !c.Auto {
waiting = append(waiting, Waiting{Spec: c.Spec, Package: c.Package, Version: c.Version, Latest: c.Latest})
}
}
for _, w := range u.Waiting {
if !asked[w.Package] {
waiting = append(waiting, w)
}
}
u.Checked, u.Waiting = now, waiting
if b, err := json.MarshalIndent(u, "", " "); err == nil {
if err := os.MkdirAll(settings.Dir(), 0o700); err == nil {
_ = writeWhole(updatesPath(), b)
}
}
updatesMu.Unlock()
return VersionChecks{At: now, Plugins: out}
}
// whyNot is why npm said nothing of a package, in a word the page says in
// its own: not reached, too many requests, an error, or no such package.
func whyNot(err error) string {
var se *statusError
var ue *url.Error
var ne net.Error
switch {
case errors.Is(err, errNotFound):
return "missing"
case errors.As(err, &se) && se.Code == http.StatusTooManyRequests:
return "limited"
case errors.As(err, &se):
return "registry"
case errors.As(err, &ue), errors.As(err, &ne), errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
return "offline"
}
return "registry"
}
+135
View File
@@ -0,0 +1,135 @@
package plugin
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Check for updates asks npm now, whatever it said within the hour: each
// plugin says whether npm has a newer version, and why it couldn't be
// checked when npm didn't say; someone else's update found waits for the
// reader (the dot on Plugins), and nothing is installed.
func TestCheckNow(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "config"))
const (
ours = "@magpie-community/opencode-zed-auth"
theirs = "opencode-gemini-auth"
current = "@magpie-community/opencode-kiro-auth"
gone = "opencode-gone-auth"
limited = "opencode-busy-auth"
broken = "opencode-broken-auth"
)
answers := map[string]string{ours: "0.1.4", theirs: "1.0.0", current: "0.2.0"}
fakeNPM(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/downloads/") {
w.Write([]byte(`{"downloads":1}`))
return
}
name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/latest")
switch name {
case limited:
http.Error(w, "slow down", http.StatusTooManyRequests)
case broken:
http.Error(w, "oops", http.StatusInternalServerError)
case gone:
http.NotFound(w, r)
default:
w.Write([]byte(`{"version":"` + answers[name] + `"}`))
}
})
folder := filepath.Join(dir, "myplugin")
if err := save(List{Plugins: []Entry{{Spec: ours}, {Spec: theirs + "@latest"}, {Spec: current}, {Spec: gone}, {Spec: limited}, {Spec: broken}, {Spec: "github:me/opencode-x-auth"}, {Spec: folder}}}); err != nil {
t.Fatal(err)
}
for _, p := range []string{ours, theirs, gone, limited, broken} {
installedAt(t, p, "0.1.2")
}
installedAt(t, current, "0.2.0")
// what npm said within the hour, before the new versions came out
npmMu.Lock()
for _, p := range []string{ours, theirs} {
npmCached()[p] = npmEntry{NPM{Version: "0.1.2"}, time.Now().Add(-time.Minute)}
}
npmMu.Unlock()
was := installLatest
t.Cleanup(func() { installLatest = was })
installLatest = func(_ context.Context, pkg string) error { t.Errorf("a check installed %s", pkg); return nil }
c := CheckNow(context.Background())
got := map[string]string{}
for _, p := range c.Plugins {
got[p.Package] = p.Status + " " + p.Why + " " + p.Version + ">" + p.Latest
}
want := map[string]string{
ours: "update 0.1.2>0.1.4",
theirs: "update 0.1.2>1.0.0",
current: "current 0.2.0>0.2.0",
gone: "unknown missing 0.1.2>",
limited: "unknown limited 0.1.2>",
broken: "unknown registry 0.1.2>",
"github:me/opencode-x-auth": "git >",
folder: "folder >",
}
for k, v := range want {
if got[k] != v {
t.Errorf("%s: %q, want %q", k, got[k], v)
}
}
if c.At.IsZero() || len(c.Plugins) != len(want) {
t.Fatalf("checked %+v", c)
}
for _, p := range c.Plugins {
if p.Auto != (p.Package == ours || p.Package == current) {
t.Errorf("%s: Auto %v", p.Package, p.Auto)
}
}
// what npm said now is what the page reads, not what it said before
if v := InfoCached([]string{ours})[ours].Version; v != "0.1.4" {
t.Errorf("kept %s, want 0.1.4", v)
}
// someone else's update waits for the reader; the community's comes
// by itself
if p := PendingUpdates(); len(p.Waiting) != 1 || p.Waiting[0].Package != theirs || p.Waiting[0].Latest != "1.0.0" || p.Checked.IsZero() {
t.Fatalf("waiting %+v", p)
}
}
// npm not reached: every plugin says it couldn't be checked, offline, and
// what waited for the reader before still waits.
func TestCheckNowOffline(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "config"))
fakeNPM(t, func(http.ResponseWriter, *http.Request) {})
down := httptest.NewServer(http.NotFoundHandler())
npmRegistry = down.URL
down.Close()
const theirs = "opencode-gemini-auth"
if err := save(List{Plugins: []Entry{{Spec: theirs}}}); err != nil {
t.Fatal(err)
}
installedAt(t, theirs, "0.1.2")
if err := os.MkdirAll(filepath.Dir(updatesPath()), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(updatesPath(), []byte(`{"waiting":[{"spec":"`+theirs+`","package":"`+theirs+`","version":"0.1.2","latest":"0.2.0"}]}`), 0o600); err != nil {
t.Fatal(err)
}
c := CheckNow(context.Background())
if len(c.Plugins) != 1 || c.Plugins[0].Status != "unknown" || c.Plugins[0].Why != "offline" || c.Plugins[0].Error == "" {
t.Fatalf("offline: %+v", c.Plugins)
}
if p := PendingUpdates(); len(p.Waiting) != 1 || p.Waiting[0].Latest != "0.2.0" {
t.Fatalf("waiting %+v", p.Waiting)
}
}