Files
olivv-cs-ComfyUI-FunPack/core/nodes_manager.py
T
digital-garbage 8318b3da0b core, app: install, update and remove node packs
Third off the port list. Users will want to add the one pack a workflow needs
without being sent to another UI to do it, and a pack that ships experimental
work needs the same for itself.

custom_nodes.py comes across as core/nodes_manager.py, changed in two places:
where FunPack's root is, and one guard that the port made necessary.

THE GUARD. custom_nodes_root() asks ComfyUI first and falls back to "FunPack
lives inside custom_nodes" -- a reliable anchor in an install, and a wrong one
in a development checkout, where the parent is somebody's home directory. Every
rule protecting the delete measures against that answer, so with ComfyUI's
paths unavailable, "remove Foo" resolved against ~/Documents and all four
guards happily agreed. It now checks the assumption it is named after and
refuses with where it thinks it is. Found by running the function, not by
reading it.

Two faults of my own, both in the window:

* Update and Remove were a button GROUP -- radios -- for two actions that are
  not a choice between two states. It also left Remove looking selected after a
  click, which is an alarming thing to look at.
* A failure was drawn and then wiped by the redraw in the same finally block:
  the message appeared and vanished in one frame. What went wrong is state now,
  not an argument to one draw call.
2026-09-06 13:56:17 +03:00

290 lines
13 KiB
Python

"""Install, update and remove ComfyUI custom node packs.
A small stand-in for ComfyUI-Manager's three operations, so FunPack users are not sent to
another UI to add the one pack a workflow needs: clone from a git URL, pull, delete.
There is no registry and no curation here — the user supplies the URL. That keeps this
honest about what it is (git, in a directory) rather than implying a vetted catalogue.
Carried across from v4 with one change -- where FunPack's root is -- because the
delete is the whole risk in it and every rule below was written against a real
way to get it wrong.
**The delete is the whole risk in this file.** Everything that resolves a pack name goes
through `node_dir`, which refuses anything that is not a direct child directory of
`custom_nodes` — resolved, so a symlink cannot point the deletion somewhere else — and
refuses FunPack itself. Nothing here interpolates into a shell.
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
from pathlib import Path
FUNPACK_ROOT = Path(__file__).resolve().parents[1]
class CustomNodeError(RuntimeError):
pass
def custom_nodes_root() -> Path:
"""The custom_nodes directory, or a refusal saying why it cannot be found.
ComfyUI's own paths are asked first. The fallback -- "FunPack lives inside
custom_nodes" -- is a reliable anchor in an INSTALL and a wrong one in a
development checkout, where the parent is somebody's home directory. v4
could rely on it because v4 only ever ran from inside custom_nodes.
So the fallback checks the assumption it is named after. Without that, a
delete in a worktree resolves against ~/Documents and every guard below
happily agrees, because they all measure against THIS answer.
"""
try:
import folder_paths
base = Path(folder_paths.base_path) / "custom_nodes"
if base.is_dir():
return base.resolve()
except Exception: # noqa: BLE001
pass
parent = FUNPACK_ROOT.parent.resolve()
if parent.name != "custom_nodes":
raise CustomNodeError(
f"Cannot tell where node packs live: ComfyUI's paths are not available and "
f"FunPack is at {FUNPACK_ROOT}, which is not inside a custom_nodes directory. "
f"Start FunPack from a ComfyUI install to manage node packs.")
return parent
def node_dir(name: str) -> Path:
"""Resolve a pack name to its directory, or refuse.
Every rule here exists to keep a delete inside custom_nodes:
* one path segment, so "../.." cannot climb out;
* resolved, so a symlink cannot redirect it;
* the resolved parent must BE custom_nodes, not merely start with it;
* never FunPack itself, which would delete the code doing the deleting.
"""
raw = str(name or "").strip()
if not raw or raw in (".", ".."):
raise CustomNodeError("A node pack name is required.")
if "/" in raw or "\\" in raw or raw.startswith("."):
raise CustomNodeError(f"{raw!r} is not a node pack name.")
root = custom_nodes_root()
path = (root / raw)
if not path.exists():
raise CustomNodeError(f"No custom node named {raw!r} is installed.")
resolved = path.resolve()
if resolved.parent != root:
raise CustomNodeError(
f"{raw!r} does not resolve to a directory inside custom_nodes — refusing to touch it.")
if not resolved.is_dir():
raise CustomNodeError(f"{raw!r} is not a directory.")
if resolved == FUNPACK_ROOT:
raise CustomNodeError(
"That is FunPack itself. Use Settings ▸ Update FunPack, or remove it by hand.")
return resolved
# ── git helpers ───────────────────────────────────────────────────────────────
def _git(cwd: Path, *args: str, timeout: int = 300) -> subprocess.CompletedProcess[str]:
if not shutil.which("git"):
raise CustomNodeError("git not found on PATH.")
return subprocess.run(["git", *args], cwd=str(cwd), capture_output=True,
text=True, timeout=timeout)
def _git_info(path: Path) -> dict:
if not (path / ".git").exists():
return {"git": False, "branch": "", "commit": "", "remote": ""}
def one(*args):
p = _git(path, *args, timeout=30)
return (p.stdout or "").strip() if p.returncode == 0 else ""
return {
"git": True,
"branch": one("rev-parse", "--abbrev-ref", "HEAD"),
"commit": one("rev-parse", "--short", "HEAD"),
"remote": one("config", "--get", "remote.origin.url"),
}
# ── listing ───────────────────────────────────────────────────────────────────
def _is_pack(p: Path) -> bool:
if not p.is_dir() or p.name.startswith(".") or p.name == "__pycache__":
return False
# ComfyUI's own disabled-pack convention, and its example directory.
return not p.name.endswith(".disabled")
def list_nodes() -> dict:
root = custom_nodes_root()
out = []
if root.is_dir():
for entry in sorted(root.iterdir(), key=lambda e: e.name.lower()):
if not _is_pack(entry):
continue
info = {"name": entry.name, "is_funpack": entry.resolve() == FUNPACK_ROOT}
info.update(_git_info(entry))
out.append(info)
return {"root": str(root), "nodes": out}
# ── install ───────────────────────────────────────────────────────────────────
# Deliberately narrow: an https/ssh git URL, nothing that could name a local path. Cloning
# is not a shell call, so this is not injection defence — it is refusing to fetch code from
# somewhere the user did not mean.
_URL_RE = re.compile(r"^(https://|git@)[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+$")
def repo_name(url: str) -> str:
"""Directory a clone of `url` would land in — git's own rule: last segment, no .git."""
tail = str(url or "").rstrip("/").rsplit("/", 1)[-1]
if tail.endswith(".git"):
tail = tail[:-4]
return tail.strip()
def install(url: str) -> dict:
"""Clone a pack into custom_nodes, then install its requirements if it has any."""
url = str(url or "").strip()
if not _URL_RE.match(url):
raise CustomNodeError(
"Enter a git URL, e.g. https://github.com/owner/repo (https:// or git@ only).")
name = repo_name(url)
if not name or "/" in name or "\\" in name or name.startswith("."):
raise CustomNodeError(f"Could not work out a directory name from {url!r}.")
root = custom_nodes_root()
target = root / name
if target.exists():
raise CustomNodeError(
f"{name!r} is already in custom_nodes. Update it instead, or remove it first.")
proc = _git(root, "clone", "--depth", "1", url, name, timeout=900)
if proc.returncode != 0:
raise CustomNodeError((proc.stderr or proc.stdout or "git clone failed").strip()[-800:])
reqs = install_requirements(target)
return {"name": name, "requirements": reqs,
"detail": (proc.stderr or proc.stdout or "").strip()[-400:]}
def install_requirements(path: Path, timeout: int = 900) -> dict | None:
"""`pip install -r requirements.txt` for one pack, into ComfyUI's interpreter.
None when the pack declares none. Never raises: the pack is on disk either way, and a
failure the user can act on beats an exception that hides what already happened.
"""
req = path / "requirements.txt"
if not req.is_file():
return None
cmd = [sys.executable, "-m", "pip", "install", "--disable-pip-version-check",
"-r", str(req)]
try:
proc = subprocess.run(cmd, cwd=str(path), capture_output=True, text=True,
timeout=timeout)
except subprocess.TimeoutExpired:
return {"ran": True, "ok": False,
"detail": f"pip did not finish within {timeout}s — run it yourself: "
f"{sys.executable} -m pip install -r {req}"}
except OSError as e:
return {"ran": True, "ok": False, "detail": f"could not run pip: {e}"}
if proc.returncode != 0:
tail = ((proc.stderr or proc.stdout or "").strip() or "pip failed")[-800:]
return {"ran": True, "ok": False,
"detail": f"pip install failed — run it yourself:\n"
f" {sys.executable} -m pip install -r {req}\n\n{tail}"}
return {"ran": True, "ok": True, "detail": (proc.stdout or "").strip()[-400:]}
# ── update / remove ───────────────────────────────────────────────────────────
def update(name: str) -> dict:
path = node_dir(name)
if not (path / ".git").exists():
raise CustomNodeError(f"{name!r} is not a git checkout — nothing to pull.")
dirty = _git(path, "status", "--porcelain", timeout=60)
if (dirty.stdout or "").strip():
raise CustomNodeError(
f"{name!r} has local changes. Commit or discard them before updating.")
before = _git_info(path).get("commit", "")
proc = _git(path, "pull", "--ff-only", timeout=600)
if proc.returncode != 0:
raise CustomNodeError((proc.stderr or proc.stdout or "git pull failed").strip()[-800:])
after = _git_info(path).get("commit", "")
reqs = install_requirements(path) if before != after else None
return {"name": name, "before": before, "after": after, "updated": before != after,
"requirements": reqs, "detail": (proc.stdout or "").strip()[-400:]}
def remove(name: str) -> dict:
"""Delete a pack's directory. Irreversible, so `node_dir` is the whole safety story."""
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}