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