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:
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
@@ -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")),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user