feat: native disable/enable fallback and package whitelist; bump to 1.8.0
Publish to Comfy registry / Publish Custom Node to registry (push) Waiting to run

Disable/enable no longer require ComfyUI Manager:
- New pack_fs.py moves packs in/out of custom_nodes/.disabled/ (no import,
  delete, or re-clone). Fallback for hand-cloned packs, loose single-file
  nodes, or when Manager is absent. enable strips the @version suffix so
  packs restore as clean, importable dir names.
- Routes: native-disable, native-enable, disabled-packs.
- Frontend routes each disable per-pack (Manager queue vs native move), and
  shows an Enable button on recoverable packs in the Uninstalled tier. The
  restart banner degrades to a manual-restart notice when no Manager exists.

Whitelist (packages-only): a star toggle protects a pack — pulled into its
own pinned group, no Disable button, skipped by the 7-day trial auto-disable.
- New whitelist_packages table; whitelisted flag on package stats.
- Routes: whitelist, whitelist/add, whitelist/remove.

Tests: test_pack_fs.py, test_whitelist.py (60 passing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 17:16:29 +02:00
parent 7ca7d95ef3
commit 6d433ba371
8 changed files with 777 additions and 54 deletions
+138
View File
@@ -0,0 +1,138 @@
"""Native enable/disable of custom-node packages, independent of ComfyUI Manager.
Disabling a pack is just a filesystem convention that ComfyUI core honors: a
directory (or ``*.py`` file) moved into ``custom_nodes/.disabled/`` is skipped at
boot, because core ignores any entry named ``.disabled`` / starting with a dot.
Manager uses the same convention. We only *move* files here — never import,
delete, or re-clone — so the operation is reversible and safe.
This is the fallback the extension uses when ComfyUI Manager is absent or does
not recognize a pack (a hand-cloned repo, an oddly-installed pack, or a loose
single-file node). A restart is required for ComfyUI to pick up the change,
exactly as with a Manager disable.
"""
import logging
import os
import shutil
logger = logging.getLogger(__name__)
# find_disabled_pack_path already knows how to locate a pack under .disabled/,
# including Manager's @version suffix. Import it in a way that works both as a
# package (runtime) and as a top-level module (tests add the root to sys.path).
try:
from .node_introspect import find_disabled_pack_path
except ImportError: # pragma: no cover - exercised via top-level import in tests
from node_introspect import find_disabled_pack_path
def _custom_node_roots():
import folder_paths
return [os.path.normpath(r) for r in folder_paths.get_folder_paths("custom_nodes")]
def _valid_name(pack_name):
"""Reject path-y input; pack names are plain directory/file names."""
return bool(pack_name) and not any(c in pack_name for c in ("/", "\\")) and ".." not in pack_name
def list_disabled_packs():
"""Return clean names of packs sitting in any custom_nodes/.disabled/ dir.
Strips a Manager ``@version`` suffix and a ``.py`` extension so the names
match package names as reported in the stats (and accepted by
``enable_pack_native``).
"""
names = set()
for root in _custom_node_roots():
ddir = os.path.join(root, ".disabled")
try:
entries = os.listdir(ddir)
except Exception:
continue
for e in entries:
if e.startswith("."):
continue
base = e.split("@", 1)[0]
stem = base[:-3] if base.endswith(".py") else base
if stem:
names.add(stem)
return names
def find_active_pack_path(pack_name):
"""Locate an installed (active) pack directly under a custom_nodes root.
Returns an absolute path to the pack directory or single ``.py`` file, or
None. Matches case-insensitively and tolerates a Manager ``@version`` suffix.
Only real packs (a directory, or a ``.py`` file) qualify.
"""
if not _valid_name(pack_name):
return None
target = pack_name.lower()
for root in _custom_node_roots():
try:
entries = os.listdir(root)
except Exception:
continue
for e in entries:
if e.startswith("."):
continue
base = e.split("@", 1)[0]
stem = base[:-3] if base.endswith(".py") else base
if target not in (stem.lower(), base.lower(), e.lower()):
continue
full = os.path.join(root, e)
if os.path.isdir(full) or (os.path.isfile(full) and full.endswith(".py")):
return os.path.normpath(full)
return None
def disable_pack_native(pack_name):
"""Move an active pack into ``custom_nodes/.disabled/``.
Returns ``(ok, message)``. Never raises for expected conditions (pack not
found, collision, permission error); those come back as ``(False, reason)``.
"""
src = find_active_pack_path(pack_name)
if not src:
return False, "pack not found on disk"
root = os.path.dirname(src)
ddir = os.path.join(root, ".disabled")
dest = os.path.join(ddir, os.path.basename(src))
if os.path.exists(dest):
return False, "a disabled copy already exists at " + dest
try:
os.makedirs(ddir, exist_ok=True)
shutil.move(src, dest)
except Exception as e:
logger.warning("nodes-stats: native disable failed for %s", pack_name, exc_info=True)
return False, str(e)
return True, "disabled"
def enable_pack_native(pack_name):
"""Move a pack from ``custom_nodes/.disabled/`` back to its root.
Returns ``(ok, message)``. Drops any Manager ``@version`` suffix from the
destination so the pack lands as a clean, importable directory name — the
same shape Manager itself restores on enable (``ComfyMath@nightly`` ->
``ComfyMath``). A single ``.py`` file keeps its extension.
"""
src = find_disabled_pack_path(pack_name)
if not src:
return False, "disabled pack not found"
ddir = os.path.dirname(src) # .../custom_nodes/.disabled
root = os.path.dirname(ddir) # .../custom_nodes
dest_name = os.path.basename(src).split("@", 1)[0]
dest = os.path.join(root, dest_name)
if os.path.exists(dest):
return False, "an active copy already exists at " + dest
try:
shutil.move(src, dest)
except Exception as e:
logger.warning("nodes-stats: native enable failed for %s", pack_name, exc_info=True)
return False, str(e)
return True, "enabled"