Offer ComfyUI-Manager install first and allow cancelling stale node installs.

This commit is contained in:
digital-garbage
2026-06-12 16:35:31 +03:00
parent 29b0b6f705
commit 656d948e0b
7 changed files with 410 additions and 68 deletions
+222 -40
View File
@@ -3,13 +3,22 @@ them through ComfyUI-Manager's queue (same path as the Manager UI)."""
from __future__ import annotations
import asyncio
import subprocess
import sys
import time
import uuid
from typing import Any, Optional
from pathlib import Path
from typing import Any, Callable, Optional
from . import config
from .builder import CORE
MANAGER_FOLDER = "ComfyUI-Manager"
MANAGER_GIT_URL = "https://github.com/ComfyUI-Manager/ComfyUI-Manager"
MANAGER_TITLE = "ComfyUI-Manager"
STALE_PROGRESS_SEC = 90
POLL_INTERVAL_SEC = 1.5
# Curated mapping: pack id (ComfyUI-Manager / registry) -> node classes used by the
# fixed core graph. FunPack and comfy-core primitives are excluded.
PIPELINE_PACKS: list[dict[str, Any]] = [
@@ -45,6 +54,27 @@ _ALL_MAPPED = frozenset().union(*(p["classes"] for p in PIPELINE_PACKS))
_install_jobs: dict[str, dict] = {}
def custom_nodes_dir() -> Path:
"""ComfyUI ``custom_nodes`` directory (FunPack lives one level below)."""
try:
import folder_paths
paths = folder_paths.get_folder_paths("custom_nodes")
if paths:
return Path(paths[0])
except Exception:
pass
return Path(__file__).resolve().parents[2].parent
def manager_dir() -> Path:
return custom_nodes_dir() / MANAGER_FOLDER
def manager_dir_on_disk() -> bool:
d = manager_dir()
return d.is_dir() and ((d / ".git").exists() or (d / "__init__.py").exists())
def required_core_classes() -> list[str]:
return sorted(set(CORE.values()))
@@ -74,15 +104,28 @@ def unmapped_missing_classes(object_info: dict | None) -> list[str]:
return sorted(missing - _ALL_MAPPED)
def status_payload(object_info: dict | None, *, manager_available: bool) -> dict:
def status_payload(
object_info: dict | None,
*,
manager_available: bool,
manager_on_disk: bool | None = None,
) -> dict:
packs = missing_packs(object_info)
unmapped = unmapped_missing_classes(object_info)
on_disk = manager_dir_on_disk() if manager_on_disk is None else manager_on_disk
needs_setup = bool(packs)
needs_manager = needs_setup and not manager_available
return {
"manager_available": manager_available,
"manager_on_disk": on_disk,
"needs_manager_install": needs_manager and not on_disk,
"needs_manager_restart": needs_manager and on_disk,
"missing_classes": missing_core_classes(object_info),
"missing_packs": packs,
"unmapped_classes": unmapped,
"needs_install": bool(packs),
"needs_install": needs_setup,
"needs_setup": needs_setup,
"manager_git_url": MANAGER_GIT_URL,
"manual_urls": [
{"id": p["id"], "title": p["title"], "url": p["git_urls"][0]}
for p in PIPELINE_PACKS
@@ -194,6 +237,11 @@ async def _start_manager_queue() -> None:
return
async def _reset_manager_queue() -> None:
for path in ("/manager/queue/reset", "/api/manager/queue/reset"):
await _manager_request("POST", path)
async def _manager_queue_status() -> dict:
for path in ("/manager/queue/status", "/api/manager/queue/status"):
status, body = await _manager_request("GET", path)
@@ -209,25 +257,84 @@ async def _manager_queue_status() -> dict:
return {}
def install_manager_sync() -> tuple[bool, str]:
"""Clone ComfyUI-Manager into custom_nodes (no Manager HTTP required)."""
import shutil
if manager_dir_on_disk():
return True, "already_installed"
if not shutil.which("git"):
return False, "git not found on PATH."
root = custom_nodes_dir()
root.mkdir(parents=True, exist_ok=True)
target = manager_dir()
proc = subprocess.run(
["git", "clone", MANAGER_GIT_URL, str(target)],
capture_output=True,
text=True,
timeout=600,
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "git clone failed").strip()
return False, err
req = target / "requirements.txt"
if req.is_file():
pip = subprocess.run(
[sys.executable, "-m", "pip", "install", "-r", str(req)],
capture_output=True,
text=True,
timeout=600,
)
if pip.returncode != 0:
err = (pip.stderr or pip.stdout or "pip install failed").strip()
return False, f"ComfyUI-Manager cloned but pip install failed: {err}"
return True, ""
def _new_job(*, kind: str, packs: list[dict]) -> dict:
job_id = uuid.uuid4().hex[:12]
job = {
"job_id": job_id,
"kind": kind,
"state": "queued",
"packs": packs,
"total": max(1, len(packs)),
"done": 0,
"current_title": packs[0]["title"] if packs else "",
"error": "",
"stale": False,
"cancel_requested": False,
"started_at": time.time(),
"last_progress_at": time.time(),
}
_install_jobs[job_id] = job
return job
def create_manager_install_job() -> dict:
return _new_job(
kind="manager",
packs=[{"id": MANAGER_FOLDER, "title": MANAGER_TITLE}],
)
def create_install_job(pack_ids: list[str]) -> dict:
packs = []
for pid in pack_ids:
spec = _PACK_BY_ID.get(pid)
if spec:
packs.append({"id": pid, "title": spec["title"]})
job_id = uuid.uuid4().hex[:12]
job = {
"job_id": job_id,
"state": "queued",
"packs": packs,
"total": len(packs),
"done": 0,
"current_title": packs[0]["title"] if packs else "",
"error": "",
"started_at": time.time(),
}
_install_jobs[job_id] = job
return job
return _new_job(kind="packs", packs=packs)
def cancel_install_job(job_id: str) -> bool:
job = _install_jobs.get(job_id)
if not job:
return False
if job["state"] in ("restarting", "cancelled", "done"):
return False
job["cancel_requested"] = True
return True
def get_install_job(job_id: str) -> Optional[dict]:
@@ -236,22 +343,119 @@ def get_install_job(job_id: str) -> Optional[dict]:
return None
return {
"job_id": job["job_id"],
"kind": job.get("kind") or "packs",
"state": job["state"],
"packs": job["packs"],
"total": job["total"],
"done": job["done"],
"current_title": job.get("current_title") or "",
"error": job.get("error") or "",
"stale": bool(job.get("stale")),
}
async def run_install_job(job_id: str, restart_fn) -> None:
def _job_snapshot(job: dict) -> dict:
return get_install_job(job["job_id"]) or {}
async def _sleep_poll(job: dict) -> bool:
"""Return True if cancel was requested during the wait."""
steps = max(1, int(POLL_INTERVAL_SEC / 0.25))
for _ in range(steps):
if job.get("cancel_requested"):
return True
await asyncio.sleep(0.25)
return bool(job.get("cancel_requested"))
async def _finish_cancelled(job: dict) -> None:
job["state"] = "cancelled"
job["current_title"] = ""
if job.get("kind") == "packs":
await _reset_manager_queue()
def _touch_progress(job: dict, done: int) -> None:
now = time.time()
if done != job.get("_last_done"):
job["_last_done"] = done
job["last_progress_at"] = now
job["stale"] = False
elif now - float(job.get("last_progress_at") or now) >= STALE_PROGRESS_SEC:
job["stale"] = True
async def _poll_manager_queue(job: dict, restart_fn: Callable[[], None]) -> None:
deadline = time.time() + 1800
while time.time() < deadline:
if job.get("cancel_requested"):
await _finish_cancelled(job)
return
st = await _manager_queue_status()
done = int(st.get("done_count") or 0)
total = int(st.get("total_count") or 0) or job["total"]
processing = bool(st.get("is_processing"))
job["total"] = max(job["total"], total)
job["done"] = min(done, job["total"])
_touch_progress(job, done)
if job["packs"]:
idx = min(max(done, 0), len(job["packs"]) - 1)
job["current_title"] = job["packs"][idx]["title"]
if job["total"] > 0 and done >= job["total"] and not processing:
break
if await _sleep_poll(job):
await _finish_cancelled(job)
return
else:
job["state"] = "error"
job["error"] = "Install timed out. Cancel and try again, or check ComfyUI-Manager in the main ComfyUI window."
job["stale"] = True
return
job["done"] = job["total"]
job["state"] = "restarting"
job["current_title"] = ""
await asyncio.sleep(0.7)
restart_fn()
async def run_manager_install_job(job_id: str, restart_fn: Callable[[], None]) -> None:
job = _install_jobs.get(job_id)
if not job:
return
job["state"] = "installing"
job["current_title"] = MANAGER_TITLE
try:
if job.get("cancel_requested"):
await _finish_cancelled(job)
return
ok, err = await asyncio.to_thread(install_manager_sync)
if job.get("cancel_requested"):
await _finish_cancelled(job)
return
if not ok:
job["state"] = "error"
job["error"] = err or "ComfyUI-Manager install failed."
return
job["done"] = 1
job["state"] = "restarting"
job["current_title"] = ""
await asyncio.sleep(0.7)
restart_fn()
except Exception as e: # noqa: BLE001
job["state"] = "error"
job["error"] = str(e) or "ComfyUI-Manager install failed."
async def run_install_job(job_id: str, restart_fn: Callable[[], None]) -> None:
job = _install_jobs.get(job_id)
if not job:
return
job["state"] = "installing"
try:
for i, pack in enumerate(job["packs"]):
if job.get("cancel_requested"):
await _finish_cancelled(job)
return
pid = pack["id"]
spec = _PACK_BY_ID.get(pid)
if not spec:
@@ -266,29 +470,7 @@ async def run_install_job(job_id: str, restart_fn) -> None:
job["error"] = err or f"Failed to queue {pack['title']}."
return
await _start_manager_queue()
deadline = time.time() + 1800
while time.time() < deadline:
st = await _manager_queue_status()
done = int(st.get("done_count") or 0)
total = int(st.get("total_count") or 0) or job["total"]
processing = bool(st.get("is_processing"))
job["total"] = max(job["total"], total)
job["done"] = min(done, job["total"])
if job["packs"]:
idx = min(max(done, 0), len(job["packs"]) - 1)
job["current_title"] = job["packs"][idx]["title"]
if job["total"] > 0 and done >= job["total"] and not processing:
break
await asyncio.sleep(1.5)
else:
job["state"] = "error"
job["error"] = "Install timed out. Check ComfyUI-Manager queue in the main ComfyUI window."
return
job["done"] = job["total"]
job["state"] = "restarting"
job["current_title"] = ""
await asyncio.sleep(0.7)
restart_fn()
await _poll_manager_queue(job, restart_fn)
except Exception as e: # noqa: BLE001
job["state"] = "error"
job["error"] = str(e) or "Install failed."
+2
View File
@@ -111,7 +111,9 @@
pipelinePorts: () => j("GET", API("/pipeline-ports")),
pipelineDeps: () => j("GET", API("/pipeline-deps")),
pipelineDepsInstall: (packIds) => j("POST", API("/pipeline-deps/install"), { pack_ids: packIds }),
pipelineDepsInstallManager: () => j("POST", API("/pipeline-deps/install"), { install_manager: true }),
pipelineDepsInstallStatus: (jobId) => j("GET", API(`/pipeline-deps/install/${encodeURIComponent(jobId)}`)),
pipelineDepsInstallCancel: (jobId) => j("POST", API(`/pipeline-deps/install/${encodeURIComponent(jobId)}/cancel`), {}),
imageTargets: (pid) => j("GET", API("/image-targets" + (pid ? `?pid=${encodeURIComponent(pid)}` : ""))),
// Models config is per-project; pass the project id. Falls back to the global
// default route (used as the seed/template) when no project is given.
+105 -21
View File
@@ -7,6 +7,7 @@
let overlay = null;
let _pollTimer = null;
let _activeJobId = null;
function dismissed() {
try { return localStorage.getItem(LS_KEY) === "1"; } catch (_) { return false; }
@@ -51,7 +52,7 @@
function manualBlock(deps) {
const box = el("div", "pipe-setup-manual");
box.append(el("div", "pipe-setup-manual-title", "Install ComfyUI-Manager or add these repos under custom_nodes:"));
box.append(el("div", "pipe-setup-manual-title", "Or clone these repos into custom_nodes manually:"));
const ul = el("ul", "pipe-setup-list");
(deps.manual_urls || []).forEach((u) => {
const li = el("li");
@@ -66,45 +67,112 @@
return box;
}
async function cancelInstall() {
const jobId = _activeJobId;
if (!jobId) {
window.FunPackRestart?.removeOverlay?.();
return;
}
try { await API.pipelineDepsInstallCancel(jobId); } catch (_) { /* server may already be gone */ }
_activeJobId = null;
if (_pollTimer) { clearTimeout(_pollTimer); _pollTimer = null; }
window.FunPackRestart?.removeOverlay?.();
}
function progressMessage(st) {
if (st.kind === "manager") {
return "Cloning ComfyUI-Manager…\nRestart follows automatically when ready.";
}
const n = Math.min(st.done + 1, st.total || 1);
const title = st.current_title || "…";
let msg = `Downloading and installing missing nodes:\n${n} out of ${st.total || "?"} - ${title}`;
if (st.stale) {
msg += "\n\nNo progress for a while. You can cancel and try again from ComfyUI-Manager.";
}
return msg;
}
async function pollInstall(jobId, msgEl) {
try {
const st = await API.pipelineDepsInstallStatus(jobId);
if (st.state === "installing" || st.state === "queued") {
const n = Math.min(st.done + 1, st.total || 1);
const title = st.current_title || "…";
msgEl.textContent = `Downloading and installing missing nodes:\n${n} out of ${st.total || "?"} - ${title}`;
msgEl.textContent = progressMessage(st);
_pollTimer = setTimeout(() => pollInstall(jobId, msgEl), 1200);
return;
}
if (st.state === "cancelled") {
_activeJobId = null;
window.FunPackRestart?.removeOverlay?.();
return;
}
if (st.state === "restarting") {
msgEl.textContent = "Install complete.\nRestarting ComfyUI…";
msgEl.textContent = st.kind === "manager"
? "ComfyUI-Manager installed.\nRestarting ComfyUI…"
: "Install complete.\nRestarting ComfyUI…";
_activeJobId = null;
window.FunPackRestart?.waitForReload?.(msgEl, Date.now());
return;
}
if (st.state === "error") {
_activeJobId = null;
window.FunPackRestart?.removeOverlay?.();
alert(st.error || "Install failed.");
return;
}
_pollTimer = setTimeout(() => pollInstall(jobId, msgEl), 1200);
} catch (e) {
_activeJobId = null;
window.FunPackRestart?.removeOverlay?.();
alert(String(e.message || e));
}
}
function beginInstallOverlay(initialMessage) {
closeModal();
const msg = window.FunPackRestart?.showOverlay?.(initialMessage, {
cancelLabel: "Cancel install",
onCancel: () => cancelInstall(),
});
return msg;
}
async function startManagerInstall() {
const msg = beginInstallOverlay("Cloning ComfyUI-Manager…\nRestart follows automatically when ready.");
if (!msg) return;
try {
const res = await API.pipelineDepsInstallManager();
_activeJobId = res.job_id;
pollInstall(res.job_id, msg);
} catch (e) {
_activeJobId = null;
window.FunPackRestart?.removeOverlay?.();
alert("Install failed: " + (e.message || e));
}
}
async function restartComfyOnly() {
closeModal();
const msg = beginInstallOverlay("Restarting ComfyUI…\nComfyUI-Manager is on disk but not loaded yet.");
if (!msg) return;
try {
await API.restart();
} catch (_) { /* expected while server exits */ }
window.FunPackRestart?.waitForReload?.(msg, Date.now());
}
async function startInstall(deps) {
const ids = (deps.missing_packs || []).map((p) => p.id);
if (!ids.length) return;
closeModal();
const msg = window.FunPackRestart?.showOverlay?.(
const msg = beginInstallOverlay(
"Downloading and installing missing nodes:\n1 out of " + ids.length + " - " + (deps.missing_packs[0]?.title || "…"),
);
if (!msg) return;
try {
const res = await API.pipelineDepsInstall(ids);
_activeJobId = res.job_id;
pollInstall(res.job_id, msg);
} catch (e) {
_activeJobId = null;
window.FunPackRestart?.removeOverlay?.();
alert("Install failed: " + (e.message || e));
}
@@ -116,21 +184,27 @@
const modal = el("div", "modal pipe-setup-modal");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", "Pipeline setup"));
const closeBtn = el("button", "btn ghost", "Close");
closeBtn.type = "button";
closeBtn.onclick = () => closeModal();
const headRight = el("div", "modal-head-right");
headRight.append(closeBtn);
head.append(headRight);
modal.append(head);
const content = el("div", "modal-content");
content.append(el("p", "pipe-setup-lead",
"Nodes required for the built-in pipeline are missing. Do you want to install them?"));
content.append(packList(deps));
if (!deps.manager_available) {
content.append(el("div", "pipe-setup-warn",
"ComfyUI-Manager was not detected. Automatic install is unavailable."));
if (deps.needs_manager_install) {
content.append(el("p", "pipe-setup-lead",
"Nodes required for the built-in pipeline are missing. Install ComfyUI-Manager first, then we can fetch the rest."));
} else if (deps.needs_manager_restart) {
content.append(el("p", "pipe-setup-lead",
"ComfyUI-Manager is on disk but not running yet. Restart ComfyUI to load it, then install the missing node packs."));
} else {
content.append(el("p", "pipe-setup-lead",
"Nodes required for the built-in pipeline are missing. Do you want to install them?"));
}
if ((deps.missing_packs || []).length) {
content.append(packList(deps));
}
if (deps.needs_manager_install) {
content.append(el("div", "pipe-setup-hint",
"ComfyUI-Manager handles downloads and updates for custom node packs."));
content.append(manualBlock(deps));
} else if (!deps.manager_available) {
content.append(manualBlock(deps));
}
if ((deps.unmapped_classes || []).length) {
@@ -140,7 +214,17 @@
modal.append(content);
const foot = el("div", "pipe-setup-foot");
if (deps.manager_available) {
if (deps.needs_manager_install) {
const mgrBtn = el("button", "btn primary", "Install ComfyUI-Manager");
mgrBtn.type = "button";
mgrBtn.onclick = () => startManagerInstall();
foot.append(mgrBtn);
} else if (deps.needs_manager_restart) {
const restartBtn = el("button", "btn primary", "Restart ComfyUI");
restartBtn.type = "button";
restartBtn.onclick = () => restartComfyOnly();
foot.append(restartBtn);
} else if (deps.manager_available) {
const installBtn = el("button", "btn primary", "Install missing nodes");
installBtn.type = "button";
installBtn.onclick = () => startInstall(deps);
@@ -168,7 +252,7 @@
if (!st.project || !builtInPipelineActive(st)) return;
let deps;
try { deps = await API.pipelineDeps(); } catch (_) { return; }
if (!deps?.needs_install) return;
if (!deps?.needs_setup) return;
openModal(deps);
}
+7 -1
View File
@@ -2,13 +2,19 @@
(function () {
const { el } = window.dom;
function showOverlay(message) {
function showOverlay(message, options) {
document.querySelector(".restart-overlay")?.remove();
const ov = el("div", "restart-overlay");
const card = el("div", "restart-card");
card.append(el("div", "restart-spin"));
const msg = el("div", "restart-msg", message);
card.append(msg);
if (options?.onCancel) {
const btn = el("button", "btn ghost restart-cancel", options.cancelLabel || "Cancel install");
btn.type = "button";
btn.onclick = options.onCancel;
card.append(btn);
}
ov.append(card);
document.body.append(ov);
return msg;
+2
View File
@@ -1261,6 +1261,8 @@ label.field.linked input, label.field.linked select { opacity: .6; }
border: 1px solid color-mix(in srgb, var(--amber) 35%, transparent); color: var(--muted); }
.pipe-setup-manual-title { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.pipe-setup-foot { display: flex; flex-wrap: wrap; gap: 8px; padding: 12px 16px 16px; border-top: 1px solid var(--line); }
.pipe-setup-hint { margin: 0 0 10px; font-size: 12px; color: var(--muted); line-height: 1.45; }
.restart-card .restart-cancel { margin-top: 4px; }
/* restart overlay */
.restart-overlay { position: fixed; inset: 0; z-index: 9999; display: flex; align-items: center; justify-content: center;
+29 -4
View File
@@ -1717,23 +1717,36 @@ if web is not None and PromptServer is not None:
except Exception:
oi = {}
mgr = await pipeline_deps.manager_available()
return web.json_response(pipeline_deps.status_payload(oi, manager_available=mgr))
return web.json_response(pipeline_deps.status_payload(
oi,
manager_available=mgr,
manager_on_disk=pipeline_deps.manager_dir_on_disk(),
))
@routes.post(UI_PREFIX + "/api/pipeline-deps/install")
async def _pipeline_deps_install(req):
import asyncio
body = await req.json() if req.can_read_body else {}
if body.get("install_manager"):
if pipeline_deps.manager_dir_on_disk():
return web.json_response(
{"detail": "ComfyUI-Manager is already present on disk. Restart ComfyUI to load it."},
status=400,
)
job = pipeline_deps.create_manager_install_job()
asyncio.create_task(pipeline_deps.run_manager_install_job(job["job_id"], _restart_comfy))
return web.json_response({"job_id": job["job_id"], "total": job["total"], "kind": "manager"})
if not await pipeline_deps.manager_available():
return web.json_response(
{"detail": "ComfyUI-Manager is not installed or not reachable."},
{"detail": "ComfyUI-Manager is not installed or not reachable. Install it first."},
status=503,
)
body = await req.json() if req.can_read_body else {}
pack_ids = body.get("pack_ids") or body.get("packs") or []
if not isinstance(pack_ids, list) or not pack_ids:
return web.json_response({"detail": "pack_ids required."}, status=400)
job = pipeline_deps.create_install_job([str(x) for x in pack_ids])
asyncio.create_task(pipeline_deps.run_install_job(job["job_id"], _restart_comfy))
return web.json_response({"job_id": job["job_id"], "total": job["total"]})
return web.json_response({"job_id": job["job_id"], "total": job["total"], "kind": "packs"})
@routes.get(UI_PREFIX + "/api/pipeline-deps/install/{job_id}")
async def _pipeline_deps_install_status(req):
@@ -1742,6 +1755,18 @@ if web is not None and PromptServer is not None:
raise web.HTTPNotFound(reason="Install job not found")
return web.json_response(job)
@routes.post(UI_PREFIX + "/api/pipeline-deps/install/{job_id}/cancel")
async def _pipeline_deps_install_cancel(req):
import asyncio
job_id = req.match_info["job_id"]
if not pipeline_deps.cancel_install_job(job_id):
job = pipeline_deps.get_install_job(job_id)
if not job:
raise web.HTTPNotFound(reason="Install job not found")
return web.json_response({"detail": "Install cannot be cancelled."}, status=409)
asyncio.create_task(pipeline_deps._reset_manager_queue())
return web.json_response({"cancelled": True, "job_id": job_id})
@routes.get(UI_PREFIX + "/api/pipeline-ports")
async def _pipeline_ports(_req):
try:
+43 -2
View File
@@ -1,4 +1,7 @@
"""Tests for built-in pipeline dependency detection."""
from pathlib import Path
from unittest.mock import patch
from movie_editor.backend import pipeline_deps
@@ -8,6 +11,7 @@ def test_no_missing_when_all_core_present():
assert pipeline_deps.missing_packs(oi) == []
st = pipeline_deps.status_payload(oi, manager_available=True)
assert st["needs_install"] is False
assert st["needs_manager_install"] is False
def test_missing_ltx_and_vhs_packs():
@@ -27,15 +31,52 @@ def test_missing_ltx_and_vhs_packs():
ids = {p["id"] for p in packs}
assert "ComfyUI-LTXVideo" in ids
assert "comfyui-videohelpersuite" in ids
st = pipeline_deps.status_payload(oi, manager_available=False)
st = pipeline_deps.status_payload(oi, manager_available=False, manager_on_disk=False)
assert st["needs_install"] is True
assert st["manager_available"] is False
assert st["needs_manager_install"] is True
assert st["needs_manager_restart"] is False
assert len(st["manual_urls"]) >= 2
def test_manager_on_disk_needs_restart_not_install():
oi = {
"FunPackStudio": {},
"FunPackLTXAVSceneChainSampler": {},
}
st = pipeline_deps.status_payload(oi, manager_available=False, manager_on_disk=True)
assert st["needs_manager_install"] is False
assert st["needs_manager_restart"] is True
def test_install_body_fallback_git():
pack = pipeline_deps._PACK_BY_ID["ComfyUI-LTXVideo"]
body = pipeline_deps._install_body(pack, None)
assert body["version"] == "unknown"
assert body["files"]
assert "github.com" in body["files"][0]
def test_cancel_install_job():
job = pipeline_deps.create_install_job(["ComfyUI-LTXVideo"])
assert pipeline_deps.cancel_install_job(job["job_id"]) is True
snap = pipeline_deps.get_install_job(job["job_id"])
assert snap is not None
def test_install_manager_sync_skips_when_present(tmp_path):
cn = tmp_path / "custom_nodes"
mgr = cn / "ComfyUI-Manager"
mgr.mkdir(parents=True)
(mgr / ".git").mkdir()
with patch.object(pipeline_deps, "custom_nodes_dir", return_value=cn):
ok, msg = pipeline_deps.install_manager_sync()
assert ok is True
assert msg == "already_installed"
def test_custom_nodes_dir_uses_folder_paths(tmp_path, monkeypatch):
cn = tmp_path / "custom_nodes"
cn.mkdir()
fp = type("FP", (), {"get_folder_paths": staticmethod(lambda _: [str(cn)])})()
monkeypatch.setitem(__import__("sys").modules, "folder_paths", fp)
assert pipeline_deps.custom_nodes_dir() == cn