feat(editor): install, update and remove custom node packs

Clone from a git URL, ff-only pull, delete. node_dir() is the whole safety
story for the delete: one path segment, resolved, must be a direct child of
custom_nodes, never FunPack itself.
This commit is contained in:
digital-garbage
2026-08-22 23:38:53 +03:00
parent cda2593997
commit ad12fa7e84
8 changed files with 676 additions and 1 deletions
+10
View File
@@ -3,6 +3,16 @@
## [Unreleased]
### Added
- **Settings ▸ Custom Nodes: install, update and remove ComfyUI node packs.** `+ Add node`
asks for a repository URL and clones it into `custom_nodes`, installing its
`requirements.txt` if it has one; each row offers Update (a fast-forward pull, refused if
the pack has local changes) and Remove. It is three git operations, not a catalogue — you
supply the URL, and nothing about the repository is vetted first, which the dialog says.
Removal names the full path it is about to delete and cannot reach outside `custom_nodes`:
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.
- **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).
Core's extension set has no `.gguf`, so those files were on disk and invisible to every
+207
View File
@@ -0,0 +1,207 @@
"""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.
**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[2]
class CustomNodeError(RuntimeError):
pass
def custom_nodes_root() -> Path:
"""The custom_nodes directory. FunPack lives inside it, which is the reliable anchor —
folder_paths is asked first only so an unusual install layout still works."""
try:
import folder_paths
base = Path(folder_paths.base_path) / "custom_nodes"
if base.is_dir():
return base.resolve()
except Exception: # noqa: BLE001
pass
return FUNPACK_ROOT.parent.resolve()
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)}
+4
View File
@@ -97,6 +97,10 @@
deleteFile: (group, name) => j("DELETE", API(`/files/${encodeURIComponent(group)}/${encodeURIComponent(name)}`)),
clearFiles: (group) => j("POST", API(`/files/${encodeURIComponent(group)}/clear`), {}),
nleLibrary: () => j("GET", API("/library/nle")),
customNodes: () => j("GET", API("/custom-nodes")),
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 }),
// media bin
listMedia: () => j("GET", API("/media")),
+193
View File
@@ -0,0 +1,193 @@
// Settings ▸ Custom Nodes — install, update and remove ComfyUI node packs.
//
// Three git operations, not a catalogue: you supply the repository URL. Keeping it that
// plain is the honest shape — a curated list would imply vetting that is not happening.
//
// Every action ends in a ComfyUI restart being NEEDED, not performed: node packs register
// at import, so nothing that happens here takes effect until the server comes back. The
// panel says so rather than restarting under the user.
(function () {
const { el, clear } = window.dom;
const API = window.MovieEditorAPI;
let host = null;
let nodes = [];
let rootPath = "";
let busy = ""; // name (or "__install__") of whatever is running
let dirty = false; // something changed → a restart is needed
let error = "";
async function refresh() {
try {
const res = await API.customNodes();
nodes = res.nodes || [];
rootPath = res.root || "";
error = "";
} catch (e) {
nodes = [];
error = e?.message || String(e);
}
render();
}
// A pack's requirements install can fail while the pack itself is fine. That is worth
// interrupting for — it is the difference between "restart and use it" and "it will not
// import until you run this".
function reportRequirements(res, what) {
const r = res && res.requirements;
if (!r || !r.ran || r.ok) return;
alert(`${what}, but installing its dependencies failed.\n\n${r.detail || ""}`);
}
async function run(key, fn, what) {
if (busy) return;
busy = key;
render();
try {
const res = await fn();
reportRequirements(res, what);
dirty = true;
} catch (e) {
alert(`${what} failed:\n\n${e?.message || e}`);
} finally {
busy = "";
await refresh();
}
}
function addDialog() {
const overlay = el("div", "modal-overlay");
const box = el("div", "modal cn-modal");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", "Add a custom node"));
box.append(head);
const content = el("div", "modal-content");
content.append(el("div", "cn-lead", "Paste the repository's git URL."));
const input = el("input", "lib-in");
input.type = "text";
input.placeholder = "https://github.com/owner/repo";
input.spellcheck = false;
content.append(input);
content.append(el("div", "cn-hint",
"It is cloned into custom_nodes and its requirements.txt is installed, if it has one. "
+ "Nothing about the repository is checked first — install what you trust."));
const acts = el("div", "lib-form-actions");
const ok = el("button", "btn primary tiny", "Install");
ok.type = "button";
const cancel = el("button", "btn ghost tiny", "Cancel");
cancel.type = "button";
const close = () => overlay.remove();
cancel.onclick = close;
ok.onclick = () => {
const url = input.value.trim();
if (!url) return;
close();
run("__install__", () => API.customNodeInstall(url), `Installing ${url}`);
};
input.onkeydown = (e) => {
if (e.key === "Enter") { e.preventDefault(); ok.click(); }
if (e.key === "Escape") { e.preventDefault(); close(); }
};
acts.append(cancel, ok);
content.append(acts);
box.append(content);
overlay.append(box);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
document.body.append(overlay);
setTimeout(() => input.focus(), 0);
}
function row(n) {
const r = el("div", "cn-row" + (n.is_funpack ? " self" : ""));
const left = el("div", "cn-row-text");
left.append(el("div", "cn-name", n.name));
const bits = [];
if (n.is_funpack) bits.push("FunPack itself");
if (n.git) bits.push(`${n.branch || "?"} · ${n.commit || "?"}`);
else bits.push("not a git checkout");
left.append(el("div", "cn-sub", bits.join(" — ")));
r.append(left);
const acts = el("div", "cn-row-acts");
if (!n.is_funpack) {
const up = el("button", "btn ghost tiny", busy === n.name ? "…" : "Update");
up.type = "button";
up.disabled = !!busy || !n.git;
up.title = n.git ? `git pull in ${n.name}` : "Not a git checkout — nothing to pull";
up.onclick = () => run(n.name, () => API.customNodeUpdate(n.name), `Updating ${n.name}`);
const rm = el("button", "btn ghost tiny danger", "Remove");
rm.type = "button";
rm.disabled = !!busy;
// The path is in the prompt on purpose: this deletes a directory and cannot be undone.
rm.onclick = () => {
if (!confirm(`Delete this folder and everything in it?\n\n${rootPath}/${n.name}\n\n`
+ "This cannot be undone.")) return;
run(n.name, () => API.customNodeRemove(n.name), `Removing ${n.name}`);
};
acts.append(up, rm);
}
r.append(acts);
return r;
}
function render() {
if (!host) return;
clear(host);
const bar = el("div", "cn-bar");
const add = el("button", "btn primary tiny", "+ Add node");
add.type = "button";
add.disabled = !!busy;
add.onclick = addDialog;
bar.append(add);
if (busy) bar.append(el("span", "cn-busy", busy === "__install__"
? "Cloning and installing…" : `Working on ${busy}…`));
host.append(bar);
if (dirty) {
const note = el("div", "cn-restart");
note.append(el("span", null,
"Node packs are registered when ComfyUI starts, so these changes are not live yet."));
const btn = el("button", "btn tiny", "Restart ComfyUI");
btn.type = "button";
// FunPackGit owns the restart: it flushes pending edits and puts up the
// wait-for-reload overlay, which a bare POST would skip.
btn.onclick = () => window.FunPackGit?.restartComfy?.();
note.append(btn);
host.append(note);
}
if (error) {
host.append(el("div", "pj-meta", "Could not read custom_nodes: " + error));
return;
}
if (!nodes.length) {
host.append(el("div", "pj-meta", "No custom node packs installed."));
return;
}
const list = el("div", "cn-list");
nodes.forEach((n) => list.append(row(n)));
host.append(list);
host.append(el("div", "cn-hint", rootPath));
}
function mount(body) {
host = el("div", "cn-mount");
body.append(host);
dirty = false;
render();
refresh();
return () => { host = null; };
}
window.SettingsWindow.register({
id: "customnodes", group: "System", order: 3, title: "Custom Nodes",
subtitle: "Install, update and remove ComfyUI node packs.",
keywords: "custom nodes packs manager install update remove git clone extension",
iconBg: "linear-gradient(180deg,#8fb8ff,#4a72c8)",
icon: '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="#fff" stroke-width="1.4"><rect x="2.2" y="2.2" width="4.6" height="4.6" rx="1"/><rect x="9.2" y="2.2" width="4.6" height="4.6" rx="1"/><rect x="2.2" y="9.2" width="4.6" height="4.6" rx="1"/><path d="M11.5 9.6v4.6M9.2 11.9h4.6"/></svg>',
mount,
});
})();
+1
View File
@@ -142,6 +142,7 @@
<script src="engine_settings.js"></script>
<script src="editor_settings.js"></script>
<script src="temp_browser.js"></script>
<script src="custom_nodes.js"></script>
<script src="autocomplete.js"></script>
<script src="suggestions.js"></script>
<script src="slot_picker.js"></script>
+29
View File
@@ -2687,3 +2687,32 @@ body.tour-mode { overflow: hidden; }
/* The close lives in the zone head and only exists while the zone is a slide-over. */
.panel-close { margin-left: auto; }
:root:not([data-ui-mode="simple"]) .panel-close { display: none; }
/* ── Settings ▸ Custom Nodes ────────────────────────────────────────────── */
.cn-mount { display: flex; flex-direction: column; gap: var(--sp-3); }
.cn-bar { display: flex; align-items: center; gap: var(--sp-2); }
.cn-busy { font-size: 11px; color: var(--muted); }
.cn-lead { font-size: 13px; color: var(--text); margin-bottom: 6px; }
.cn-hint { font-size: 11px; color: var(--faint); line-height: 1.45; margin-top: 6px;
overflow-wrap: anywhere; }
.cn-modal { max-width: 460px; }
.cn-list { display: flex; flex-direction: column; gap: 4px; }
.cn-row {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
padding: 8px 10px; border-radius: 8px;
background: var(--ink-2); border: 1px solid var(--line);
}
.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)); }
.cn-row-text { min-width: 0; }
.cn-name { font-size: 12px; font-weight: 600; color: var(--text);
overflow-wrap: anywhere; }
.cn-sub { font-size: 10px; color: var(--faint); font-family: var(--font-mono);
margin-top: 2px; overflow-wrap: anywhere; }
.cn-row-acts { display: flex; gap: 4px; flex-shrink: 0; }
.cn-restart {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
padding: 8px 10px; border-radius: 8px; font-size: 11px; line-height: 1.45;
background: color-mix(in srgb, var(--accent) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--line));
}
+42 -1
View File
@@ -20,7 +20,7 @@ except Exception: # pragma: no cover - only available inside ComfyUI
web = None
PromptServer = None
from .backend import bridge, builder, config, git_update, media, model_probe, nodes, pipeline_caps, pipeline_deps, pipeline_wiring, projects, sysinfo, workflow_import
from .backend import bridge, builder, config, custom_nodes, git_update, media, model_probe, nodes, pipeline_caps, pipeline_deps, pipeline_wiring, projects, sysinfo, workflow_import
from .backend.nle_effects import geometry_filters, reverse_refusal, zoompan_z_expr
from .backend.nle_overlays import build_overlay_video_filter, prepare_overlay_export
from .backend.timeline import (
@@ -2535,6 +2535,47 @@ if web is not None and PromptServer is not None:
asyncio.get_event_loop().call_later(0.7, _restart_comfy)
return web.json_response({"restarting": True, **result})
# --- API: custom node packs (a small stand-in for ComfyUI-Manager: clone, pull, delete) ---
# Every one of these shells out to git or pip, which take seconds to minutes. Inline they
# would freeze the one event loop ComfyUI has, stalling playback and the whole editor API.
@routes.get(UI_PREFIX + "/api/custom-nodes")
async def _custom_nodes_list(_req):
import asyncio
return web.json_response(await asyncio.to_thread(custom_nodes.list_nodes))
@routes.post(UI_PREFIX + "/api/custom-nodes/install")
async def _custom_nodes_install(req):
import asyncio
body = await req.json() if req.can_read_body else {}
try:
result = await asyncio.to_thread(custom_nodes.install, str(body.get("url") or ""))
except custom_nodes.CustomNodeError as e:
return web.json_response({"detail": str(e)}, status=400)
return web.json_response(result)
@routes.post(UI_PREFIX + "/api/custom-nodes/update")
async def _custom_nodes_update(req):
import asyncio
body = await req.json() if req.can_read_body else {}
try:
result = await asyncio.to_thread(custom_nodes.update, str(body.get("name") or ""))
except custom_nodes.CustomNodeError as e:
return web.json_response({"detail": str(e)}, status=400)
return web.json_response(result)
@routes.post(UI_PREFIX + "/api/custom-nodes/remove")
async def _custom_nodes_remove(req):
import asyncio
body = await req.json() if req.can_read_body else {}
try:
result = await asyncio.to_thread(custom_nodes.remove, str(body.get("name") or ""))
except custom_nodes.CustomNodeError as e:
return web.json_response({"detail": str(e)}, status=400)
except OSError as e:
return web.json_response({"detail": f"Could not delete it: {e}"}, status=500)
return web.json_response(result)
@routes.post(UI_PREFIX + "/api/restart")
async def _restart(_req):
import asyncio
+190
View File
@@ -0,0 +1,190 @@
"""Install / update / remove for custom node packs.
Most of this file is about `node_dir`. It is the only thing standing between a name typed
into a web UI and `shutil.rmtree`, so every way of escaping custom_nodes gets a test.
"""
import sys
import types
from pathlib import Path
import pytest
from movie_editor.backend import custom_nodes as cn
@pytest.fixture
def root(tmp_path, monkeypatch):
"""A fake custom_nodes with FunPack and one other pack in it."""
cnodes = tmp_path / "custom_nodes"
(cnodes / "ComfyUI-FunPack").mkdir(parents=True)
(cnodes / "ComfyUI-GGUF").mkdir()
(cnodes / "SomePack").mkdir()
monkeypatch.setattr(cn, "custom_nodes_root", lambda: cnodes.resolve())
monkeypatch.setattr(cn, "FUNPACK_ROOT", (cnodes / "ComfyUI-FunPack").resolve())
return cnodes
# ── node_dir: the guard in front of rmtree ────────────────────────────────────
def test_resolves_a_real_pack(root):
assert cn.node_dir("ComfyUI-GGUF") == (root / "ComfyUI-GGUF").resolve()
@pytest.mark.parametrize("name", [
"", " ", ".", "..", "../..", "../../etc", "sub/dir", "sub\\dir",
".hidden", "/etc", "\\windows",
])
def test_path_escapes_are_refused(root, name):
with pytest.raises(cn.CustomNodeError):
cn.node_dir(name)
def test_a_symlink_pointing_outside_is_refused(root, tmp_path):
"""The name is one segment and the entry really is in custom_nodes — but following it
lands elsewhere, which is exactly what resolving before checking is for."""
outside = tmp_path / "not_custom_nodes"
outside.mkdir()
try:
(root / "sneaky").symlink_to(outside, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlinks unavailable here")
with pytest.raises(cn.CustomNodeError, match="inside custom_nodes"):
cn.node_dir("sneaky")
def test_funpack_cannot_delete_itself(root):
with pytest.raises(cn.CustomNodeError, match="FunPack itself"):
cn.node_dir("ComfyUI-FunPack")
def test_a_missing_pack_is_refused_not_created(root):
with pytest.raises(cn.CustomNodeError, match="No custom node"):
cn.node_dir("NeverInstalled")
def test_a_file_is_not_a_pack(root):
(root / "notes.txt").write_text("x")
with pytest.raises(cn.CustomNodeError, match="not a directory"):
cn.node_dir("notes.txt")
def test_remove_deletes_only_that_directory(root):
cn.remove("SomePack")
assert not (root / "SomePack").exists()
assert (root / "ComfyUI-GGUF").is_dir()
assert (root / "ComfyUI-FunPack").is_dir()
def test_remove_refuses_what_node_dir_refuses(root):
"""rmtree is never reached on a name node_dir rejects — the same guard, one path in."""
with pytest.raises(cn.CustomNodeError):
cn.remove("../..")
assert root.exists()
# ── install: URLs ─────────────────────────────────────────────────────────────
@pytest.mark.parametrize("url,expected", [
("https://github.com/owner/repo", "repo"),
("https://github.com/owner/repo.git", "repo"),
("https://github.com/owner/repo/", "repo"),
("git@github.com:owner/repo.git", "repo"),
])
def test_directory_name_follows_gits_own_rule(url, expected):
assert cn.repo_name(url) == expected
@pytest.mark.parametrize("url", [
"", "not a url", "ftp://example.com/repo", "file:///etc",
"/local/path", "../repo", "javascript:alert(1)",
])
def test_non_git_urls_are_refused(root, url):
with pytest.raises(cn.CustomNodeError, match="git URL"):
cn.install(url)
def test_installing_over_an_existing_pack_is_refused(root):
with pytest.raises(cn.CustomNodeError, match="already in custom_nodes"):
cn.install("https://github.com/owner/ComfyUI-GGUF")
def test_install_clones_then_installs_requirements(root, monkeypatch):
calls = []
def fake_git(cwd, *args, **kw):
calls.append(args)
(Path(cwd) / args[-1]).mkdir()
(Path(cwd) / args[-1] / "requirements.txt").write_text("numpy\n")
return types.SimpleNamespace(returncode=0, stdout="cloned", stderr="")
monkeypatch.setattr(cn, "_git", fake_git)
monkeypatch.setattr(cn.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(
returncode=0, stdout="Successfully installed numpy", stderr=""))
out = cn.install("https://github.com/owner/NewPack")
assert out["name"] == "NewPack"
assert calls[0][0] == "clone"
assert out["requirements"]["ok"] is True
def test_a_pack_without_requirements_installs_nothing(root, monkeypatch):
monkeypatch.setattr(cn.subprocess, "run",
lambda *a, **k: pytest.fail("pip must not run"))
assert cn.install_requirements(root / "SomePack") is None
def test_a_failed_pip_reports_instead_of_raising(root, monkeypatch):
"""The pack is on disk either way; an exception here would hide that it was cloned."""
(root / "SomePack" / "requirements.txt").write_text("nope\n")
monkeypatch.setattr(cn.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(
returncode=1, stdout="", stderr="No matching distribution found for nope"))
out = cn.install_requirements(root / "SomePack")
assert out["ok"] is False
assert "pip install -r" in out["detail"]
assert sys.executable in out["detail"]
# ── update ────────────────────────────────────────────────────────────────────
def test_update_refuses_a_non_git_pack(root):
with pytest.raises(cn.CustomNodeError, match="not a git checkout"):
cn.update("SomePack")
def test_update_refuses_to_discard_local_changes(root, monkeypatch):
(root / "SomePack" / ".git").mkdir()
monkeypatch.setattr(cn, "_git", lambda cwd, *a, **k: types.SimpleNamespace(
returncode=0, stdout=" M nodes.py\n", stderr=""))
with pytest.raises(cn.CustomNodeError, match="local changes"):
cn.update("SomePack")
def test_update_skips_pip_when_nothing_moved(root, monkeypatch):
(root / "SomePack" / ".git").mkdir()
monkeypatch.setattr(cn, "_git", lambda cwd, *a, **k: types.SimpleNamespace(
returncode=0, stdout="", stderr=""))
monkeypatch.setattr(cn, "_git_info", lambda p: {"commit": "same"})
monkeypatch.setattr(cn, "install_requirements",
lambda *a, **k: pytest.fail("pip must not run"))
out = cn.update("SomePack")
assert out["updated"] is False and out["requirements"] is None
# ── listing ───────────────────────────────────────────────────────────────────
def test_listing_names_funpack_so_the_ui_can_protect_it(root, monkeypatch):
monkeypatch.setattr(cn, "_git_info", lambda p: {"git": False, "branch": "",
"commit": "", "remote": ""})
names = {n["name"]: n for n in cn.list_nodes()["nodes"]}
assert names["ComfyUI-FunPack"]["is_funpack"] is True
assert names["SomePack"]["is_funpack"] is False
def test_listing_skips_dotfiles_and_disabled_packs(root, monkeypatch):
(root / "__pycache__").mkdir()
(root / ".git").mkdir()
(root / "Old.disabled").mkdir()
monkeypatch.setattr(cn, "_git_info", lambda p: {"git": False, "branch": "",
"commit": "", "remote": ""})
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