feat(editor): check custom node packs for updates

Fetches each pack's origin four at a time and reports behind/ahead on the
row. Anything uncomparable says why rather than reading as up to date.
This commit is contained in:
digital-garbage
2026-08-22 23:42:40 +03:00
parent ad12fa7e84
commit bb3e481f29
7 changed files with 209 additions and 1 deletions
+5
View File
@@ -12,6 +12,11 @@
the name must be a single path segment that resolves to a direct child directory, so a
symlink cannot redirect it, and FunPack cannot delete itself. Node packs register at
import, so the panel says a restart is needed rather than restarting under you.
**Check for updates** fetches each pack's origin and shows how far behind it is, on the
row and on its Update button. It is a button rather than part of the listing because it
costs a network round trip per pack; the fetches run four at a time, and a pack that
cannot be compared says why (detached HEAD, no remote, origin unreachable) instead of
quietly reading as up to date.
- **FunPack's loaders take `.gguf` files**, for diffusion models and text encoders alike, and
a text-encoder list may mix a `.gguf` with `.safetensors` slots (the usual LTX-2.3 shape).
+63
View File
@@ -205,3 +205,66 @@ def remove(name: str) -> dict:
path = node_dir(name)
shutil.rmtree(path)
return {"name": name, "removed": True, "path": str(path)}
# ── checking for updates ──────────────────────────────────────────────────────
def _behind_ahead(path: Path) -> dict:
"""How far one pack is from its upstream, after fetching it.
Costs a network round trip per pack, which is why it is a button rather than part of
the listing: on a tunnelled instance with a dozen packs, doing this on open would make
the panel feel broken.
"""
if not (path / ".git").exists():
return {"checked": False, "reason": "not a git checkout"}
br = _git(path, "rev-parse", "--abbrev-ref", "HEAD", timeout=30)
branch = (br.stdout or "").strip()
if br.returncode != 0 or not branch or branch == "HEAD":
return {"checked": False, "reason": "not on a branch (detached HEAD)"}
remote = _git(path, "config", "--get", "remote.origin.url", timeout=30)
if remote.returncode != 0 or not (remote.stdout or "").strip():
return {"checked": False, "reason": "no origin remote"}
fetched = _git(path, "fetch", "--quiet", "origin", branch, timeout=180)
if fetched.returncode != 0:
return {"checked": False,
"reason": (fetched.stderr or "could not reach origin").strip()[-200:]}
counts = _git(path, "rev-list", "--left-right", "--count",
f"HEAD...origin/{branch}", timeout=60)
if counts.returncode != 0:
return {"checked": False, "reason": "no upstream branch to compare against"}
parts = (counts.stdout or "").split()
if len(parts) != 2:
return {"checked": False, "reason": "could not compare with origin"}
ahead, behind = int(parts[0]), int(parts[1])
return {"checked": True, "branch": branch, "ahead": ahead, "behind": behind}
def check_updates() -> dict:
"""Fetch every git pack and report how far behind each one is.
Fetches run concurrently and bounded: they are network-bound, so serial would be as slow
as the sum of them, and unbounded would open a connection per pack at once.
"""
from concurrent.futures import ThreadPoolExecutor
packs = [n for n in list_nodes()["nodes"]]
targets = [p for p in packs if p.get("git")]
results: dict[str, dict] = {}
if targets:
with ThreadPoolExecutor(max_workers=4) as pool:
futures = {}
for p in targets:
try:
futures[pool.submit(_behind_ahead, node_dir(p["name"]))] = p["name"]
except CustomNodeError as e:
results[p["name"]] = {"checked": False, "reason": str(e)}
for fut, name in futures.items():
try:
results[name] = fut.result()
except Exception as e: # noqa: BLE001 — one bad pack must not sink the sweep
results[name] = {"checked": False, "reason": str(e)[-200:]}
for p in packs:
if p["name"] not in results:
results[p["name"]] = {"checked": False, "reason": "not a git checkout"}
return {"checked": results}
+1
View File
@@ -98,6 +98,7 @@
clearFiles: (group) => j("POST", API(`/files/${encodeURIComponent(group)}/clear`), {}),
nleLibrary: () => j("GET", API("/library/nle")),
customNodes: () => j("GET", API("/custom-nodes")),
customNodesCheck: () => j("POST", API("/custom-nodes/check"), {}),
customNodeInstall: (url) => j("POST", API("/custom-nodes/install"), { url }),
customNodeUpdate: (name) => j("POST", API("/custom-nodes/update"), { name }),
customNodeRemove: (name) => j("POST", API("/custom-nodes/remove"), { name }),
+42 -1
View File
@@ -16,6 +16,24 @@
let busy = ""; // name (or "__install__") of whatever is running
let dirty = false; // something changed → a restart is needed
let error = "";
// name -> {checked, behind, ahead, reason}. Empty until the user asks: each entry costs a
// network round trip, and doing that on open would make the panel feel broken.
let checked = {};
let checking = false;
async function checkUpdates() {
if (checking || busy) return;
checking = true;
render();
try {
checked = (await API.customNodesCheck()).checked || {};
} catch (e) {
alert("Could not check for updates:\n\n" + (e?.message || e));
} finally {
checking = false;
render();
}
}
async function refresh() {
try {
@@ -47,6 +65,9 @@
const res = await fn();
reportRequirements(res, what);
dirty = true;
// Whatever this was, its behind-count is now stale. Dropping it beats showing a
// number that was true a moment ago.
if (key !== "__install__") delete checked[key];
} catch (e) {
alert(`${what} failed:\n\n${e?.message || e}`);
} finally {
@@ -107,12 +128,23 @@
if (n.is_funpack) bits.push("FunPack itself");
if (n.git) bits.push(`${n.branch || "?"} · ${n.commit || "?"}`);
else bits.push("not a git checkout");
const chk = checked[n.name];
if (chk && chk.checked) {
if (chk.behind > 0) bits.push(`${chk.behind} behind`);
else if (chk.ahead > 0) bits.push(`${chk.ahead} ahead of origin`);
else bits.push("up to date");
} else if (chk && chk.reason) {
bits.push(`not compared — ${chk.reason}`);
}
left.append(el("div", "cn-sub", bits.join(" — ")));
r.append(left);
const acts = el("div", "cn-row-acts");
if (chk && chk.checked && chk.behind > 0) r.classList.add("behind");
if (!n.is_funpack) {
const up = el("button", "btn ghost tiny", busy === n.name ? "…" : "Update");
const label = busy === n.name ? "…"
: (chk && chk.checked && chk.behind > 0) ? `Update (${chk.behind})` : "Update";
const up = el("button", "btn ghost tiny" + (chk?.behind > 0 ? " primary" : ""), label);
up.type = "button";
up.disabled = !!busy || !n.git;
up.title = n.git ? `git pull in ${n.name}` : "Not a git checkout — nothing to pull";
@@ -142,8 +174,16 @@
add.disabled = !!busy;
add.onclick = addDialog;
bar.append(add);
const chkBtn = el("button", "btn ghost tiny", checking ? "Checking…" : "Check for updates");
chkBtn.type = "button";
chkBtn.disabled = checking || !!busy;
chkBtn.title = "Fetch each pack's origin and report how far behind it is";
chkBtn.onclick = checkUpdates;
bar.append(chkBtn);
if (busy) bar.append(el("span", "cn-busy", busy === "__install__"
? "Cloning and installing…" : `Working on ${busy}…`));
else if (checking) bar.append(el("span", "cn-busy",
"Fetching each pack — this talks to the network."));
host.append(bar);
if (dirty) {
@@ -177,6 +217,7 @@
host = el("div", "cn-mount");
body.append(host);
dirty = false;
checked = {};
render();
refresh();
return () => { host = null; };
+2
View File
@@ -2704,6 +2704,8 @@ body.tour-mode { overflow: hidden; }
}
.cn-row.self { background: color-mix(in srgb, var(--accent) 10%, var(--ink-2));
border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
/* Behind origin — the one row state worth spotting without reading. */
.cn-row.behind { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); }
.cn-row-text { min-width: 0; }
.cn-name { font-size: 12px; font-weight: 600; color: var(--text);
overflow-wrap: anywhere; }
+5
View File
@@ -2544,6 +2544,11 @@ if web is not None and PromptServer is not None:
import asyncio
return web.json_response(await asyncio.to_thread(custom_nodes.list_nodes))
@routes.post(UI_PREFIX + "/api/custom-nodes/check")
async def _custom_nodes_check(_req):
import asyncio
return web.json_response(await asyncio.to_thread(custom_nodes.check_updates))
@routes.post(UI_PREFIX + "/api/custom-nodes/install")
async def _custom_nodes_install(req):
import asyncio
+91
View File
@@ -188,3 +188,94 @@ def test_listing_skips_dotfiles_and_disabled_packs(root, monkeypatch):
names = [n["name"] for n in cn.list_nodes()["nodes"]]
assert "__pycache__" not in names and ".git" not in names
assert "Old.disabled" not in names
# ── check for updates ─────────────────────────────────────────────────────────
# A network round trip per pack, which is why it is a button. Every failure mode has to
# produce a reason rather than an absent answer, or a pack silently reads as "fine".
def test_behind_ahead_parses_the_counts(root, monkeypatch):
(root / "SomePack" / ".git").mkdir()
def fake_git(cwd, *args, **kw):
if args[0] == "rev-parse":
return types.SimpleNamespace(returncode=0, stdout="main\n", stderr="")
if args[0] == "config":
return types.SimpleNamespace(returncode=0, stdout="https://x/y\n", stderr="")
if args[0] == "fetch":
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
return types.SimpleNamespace(returncode=0, stdout="2\t7\n", stderr="")
monkeypatch.setattr(cn, "_git", fake_git)
out = cn._behind_ahead(root / "SomePack")
assert out == {"checked": True, "branch": "main", "ahead": 2, "behind": 7}
def test_a_detached_head_says_so(root, monkeypatch):
(root / "SomePack" / ".git").mkdir()
monkeypatch.setattr(cn, "_git", lambda cwd, *a, **k: types.SimpleNamespace(
returncode=0, stdout="HEAD\n", stderr=""))
out = cn._behind_ahead(root / "SomePack")
assert out["checked"] is False and "detached" in out["reason"]
def test_an_unreachable_origin_says_so(root, monkeypatch):
(root / "SomePack" / ".git").mkdir()
def fake_git(cwd, *args, **kw):
if args[0] == "rev-parse":
return types.SimpleNamespace(returncode=0, stdout="main\n", stderr="")
if args[0] == "config":
return types.SimpleNamespace(returncode=0, stdout="https://x/y\n", stderr="")
return types.SimpleNamespace(returncode=128, stdout="",
stderr="fatal: could not read from remote")
monkeypatch.setattr(cn, "_git", fake_git)
out = cn._behind_ahead(root / "SomePack")
assert out["checked"] is False and "remote" in out["reason"]
def test_a_pack_with_no_remote_is_not_compared(root, monkeypatch):
(root / "SomePack" / ".git").mkdir()
def fake_git(cwd, *args, **kw):
if args[0] == "rev-parse":
return types.SimpleNamespace(returncode=0, stdout="main\n", stderr="")
return types.SimpleNamespace(returncode=1, stdout="", stderr="")
monkeypatch.setattr(cn, "_git", fake_git)
assert cn._behind_ahead(root / "SomePack")["reason"] == "no origin remote"
def test_a_non_git_pack_is_reported_not_omitted(root):
out = cn._behind_ahead(root / "SomePack")
assert out == {"checked": False, "reason": "not a git checkout"}
def test_every_pack_appears_in_the_result(root, monkeypatch):
"""Including the ones that could not be checked — an absent entry would read as
'nothing to say', which is not the same as 'could not tell'."""
monkeypatch.setattr(cn, "_git_info", lambda p: {
"git": p.name == "ComfyUI-GGUF", "branch": "main", "commit": "abc", "remote": "x"})
monkeypatch.setattr(cn, "_behind_ahead",
lambda p: {"checked": True, "branch": "main", "ahead": 0, "behind": 3})
got = cn.check_updates()["checked"]
assert set(got) == {"ComfyUI-FunPack", "ComfyUI-GGUF", "SomePack"}
assert got["ComfyUI-GGUF"]["behind"] == 3
assert got["SomePack"]["checked"] is False
def test_one_failing_pack_does_not_sink_the_sweep(root, monkeypatch):
monkeypatch.setattr(cn, "_git_info", lambda p: {
"git": True, "branch": "main", "commit": "abc", "remote": "x"})
def flaky(path):
if path.name == "SomePack":
raise RuntimeError("git exploded")
return {"checked": True, "branch": "main", "ahead": 0, "behind": 1}
monkeypatch.setattr(cn, "_behind_ahead", flaky)
got = cn.check_updates()["checked"]
assert got["SomePack"]["checked"] is False
assert "exploded" in got["SomePack"]["reason"]
assert got["ComfyUI-GGUF"]["behind"] == 1