From ad12fa7e845294a714bb6b3e82ccf104cf1ed622 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:38:53 +0300 Subject: [PATCH] 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. --- CHANGELOG.md | 10 ++ movie_editor/backend/custom_nodes.py | 207 ++++++++++++++++++++++++ movie_editor/frontend/api.js | 4 + movie_editor/frontend/custom_nodes.js | 193 ++++++++++++++++++++++ movie_editor/frontend/index.html | 1 + movie_editor/frontend/styles.css | 29 ++++ movie_editor/server.py | 43 ++++- movie_editor/tests/test_custom_nodes.py | 190 ++++++++++++++++++++++ 8 files changed, 676 insertions(+), 1 deletion(-) create mode 100644 movie_editor/backend/custom_nodes.py create mode 100644 movie_editor/frontend/custom_nodes.js create mode 100644 movie_editor/tests/test_custom_nodes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 869655de..45e0c7a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/movie_editor/backend/custom_nodes.py b/movie_editor/backend/custom_nodes.py new file mode 100644 index 00000000..a7468cd9 --- /dev/null +++ b/movie_editor/backend/custom_nodes.py @@ -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)} diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index 94c1328e..cadfc077 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -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")), diff --git a/movie_editor/frontend/custom_nodes.js b/movie_editor/frontend/custom_nodes.js new file mode 100644 index 00000000..7db1e057 --- /dev/null +++ b/movie_editor/frontend/custom_nodes.js @@ -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: '', + mount, + }); +})(); diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html index f4032a3f..411e4716 100644 --- a/movie_editor/frontend/index.html +++ b/movie_editor/frontend/index.html @@ -142,6 +142,7 @@ + diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index e2da277f..57ab348c 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -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)); +} diff --git a/movie_editor/server.py b/movie_editor/server.py index 120612b8..25eaba08 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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 diff --git a/movie_editor/tests/test_custom_nodes.py b/movie_editor/tests/test_custom_nodes.py new file mode 100644 index 00000000..cc97bd65 --- /dev/null +++ b/movie_editor/tests/test_custom_nodes.py @@ -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