"""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"