diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index dea7699c..ad6def81 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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} 有新版本", diff --git a/internal/gui/assets/plugins.css b/internal/gui/assets/plugins.css index 23eafaa4..5d205d6a 100644 --- a/internal/gui/assets/plugins.css +++ b/internal/gui/assets/plugins.css @@ -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 */ diff --git a/internal/gui/assets/plugins.js b/internal/gui/assets/plugins.js index 4ae1f60f..c26704c3 100644 --- a/internal/gui/assets/plugins.js +++ b/internal/gui/assets/plugins.js @@ -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) { diff --git a/internal/gui/plugins.go b/internal/gui/plugins.go index 06b52729..978c5f9a 100644 --- a/internal/gui/plugins.go +++ b/internal/gui/plugins.go @@ -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 { diff --git a/internal/gui/plugins_check_test.go b/internal/gui/plugins_check_test.go new file mode 100644 index 00000000..61544fc3 --- /dev/null +++ b/internal/gui/plugins_check_test.go @@ -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) + } +} diff --git a/internal/gui/tests/plugin-check-updates.test.cjs b/internal/gui/tests/plugin-check-updates.test.cjs new file mode 100644 index 00000000..065f9da2 --- /dev/null +++ b/internal/gui/tests/plugin-check-updates.test.cjs @@ -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, []); + }); + } + }); +} diff --git a/internal/plugin/market.go b/internal/plugin/market.go index 94ad21e1..b72fdb90 100644 --- a/internal/plugin/market.go +++ b/internal/plugin/market.go @@ -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. diff --git a/internal/plugin/updates.go b/internal/plugin/updates.go index 0f83de72..0a34f5a9 100644 --- a/internal/plugin/updates.go +++ b/internal/plugin/updates.go @@ -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" +} diff --git a/internal/plugin/updates_check_test.go b/internal/plugin/updates_check_test.go new file mode 100644 index 00000000..9d503b0f --- /dev/null +++ b/internal/plugin/updates_check_test.go @@ -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) + } +}