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
+150
View File
@@ -0,0 +1,150 @@
import os
import pytest
import pack_fs
@pytest.fixture
def custom_nodes(tmp_path):
"""A custom_nodes root wired into the mocked folder_paths."""
import folder_paths
root = tmp_path / "custom_nodes"
root.mkdir()
folder_paths.get_folder_paths.return_value = [str(root)]
return root
def _make_pack(root, name):
pack = root / name
pack.mkdir()
(pack / "__init__.py").write_text("NODE_CLASS_MAPPINGS = {}\n")
return pack
# --- find_active_pack_path ------------------------------------------------
def test_find_active_dir(custom_nodes):
_make_pack(custom_nodes, "MyPack")
found = pack_fs.find_active_pack_path("MyPack")
assert found == os.path.normpath(str(custom_nodes / "MyPack"))
def test_find_active_case_insensitive(custom_nodes):
_make_pack(custom_nodes, "MyPack")
assert pack_fs.find_active_pack_path("mypack") is not None
def test_find_active_single_py_file(custom_nodes):
(custom_nodes / "loose_node.py").write_text("NODE_CLASS_MAPPINGS = {}\n")
found = pack_fs.find_active_pack_path("loose_node")
assert found == os.path.normpath(str(custom_nodes / "loose_node.py"))
def test_find_active_ignores_disabled_dir(custom_nodes):
(custom_nodes / ".disabled").mkdir()
_make_pack(custom_nodes / ".disabled", "MyPack")
assert pack_fs.find_active_pack_path("MyPack") is None
def test_find_active_rejects_path_traversal(custom_nodes):
assert pack_fs.find_active_pack_path("../evil") is None
assert pack_fs.find_active_pack_path("a/b") is None
assert pack_fs.find_active_pack_path("") is None
# --- disable_pack_native --------------------------------------------------
def test_disable_moves_dir_into_disabled(custom_nodes):
_make_pack(custom_nodes, "MyPack")
ok, msg = pack_fs.disable_pack_native("MyPack")
assert ok, msg
assert not (custom_nodes / "MyPack").exists()
assert (custom_nodes / ".disabled" / "MyPack" / "__init__.py").exists()
def test_disable_moves_single_py_file(custom_nodes):
(custom_nodes / "loose_node.py").write_text("x = 1\n")
ok, msg = pack_fs.disable_pack_native("loose_node")
assert ok, msg
assert not (custom_nodes / "loose_node.py").exists()
assert (custom_nodes / ".disabled" / "loose_node.py").exists()
def test_disable_missing_pack_fails(custom_nodes):
ok, msg = pack_fs.disable_pack_native("Ghost")
assert not ok
assert "not found" in msg
def test_disable_collision_fails(custom_nodes):
_make_pack(custom_nodes, "MyPack")
(custom_nodes / ".disabled").mkdir()
(custom_nodes / ".disabled" / "MyPack").mkdir() # pre-existing disabled copy
ok, msg = pack_fs.disable_pack_native("MyPack")
assert not ok
assert "already exists" in msg
# original left untouched
assert (custom_nodes / "MyPack").exists()
# --- enable_pack_native ---------------------------------------------------
def test_enable_moves_back(custom_nodes):
_make_pack(custom_nodes, "MyPack")
assert pack_fs.disable_pack_native("MyPack")[0]
ok, msg = pack_fs.enable_pack_native("MyPack")
assert ok, msg
assert (custom_nodes / "MyPack" / "__init__.py").exists()
assert not (custom_nodes / ".disabled" / "MyPack").exists()
def test_enable_missing_fails(custom_nodes):
ok, msg = pack_fs.enable_pack_native("Ghost")
assert not ok
assert "not found" in msg
def test_enable_strips_version_suffix(custom_nodes):
# A Manager-disabled pack on disk carries an @version suffix; enabling should
# restore it as a clean, importable directory name.
ddir = custom_nodes / ".disabled"
ddir.mkdir()
pack = ddir / "ComfyMath@nightly"
pack.mkdir()
(pack / "__init__.py").write_text("NODE_CLASS_MAPPINGS = {}\n")
ok, msg = pack_fs.enable_pack_native("ComfyMath")
assert ok, msg
assert (custom_nodes / "ComfyMath" / "__init__.py").exists()
assert not (custom_nodes / "ComfyMath@nightly").exists()
assert not (ddir / "ComfyMath@nightly").exists()
def test_disable_then_enable_roundtrip_py_file(custom_nodes):
(custom_nodes / "loose_node.py").write_text("x = 1\n")
assert pack_fs.disable_pack_native("loose_node")[0]
assert pack_fs.enable_pack_native("loose_node")[0]
assert (custom_nodes / "loose_node.py").exists()
# --- list_disabled_packs --------------------------------------------------
def test_list_disabled_empty(custom_nodes):
assert pack_fs.list_disabled_packs() == set()
def test_list_disabled_strips_version_and_ext(custom_nodes):
ddir = custom_nodes / ".disabled"
ddir.mkdir()
(ddir / "ComfyMath@nightly").mkdir() # Manager-style @version suffix
(ddir / "PlainPack").mkdir()
(ddir / "loose_node.py").write_text("x = 1\n") # single-file pack
assert pack_fs.list_disabled_packs() == {"ComfyMath", "PlainPack", "loose_node"}
def test_list_disabled_reflects_a_native_disable(custom_nodes):
_make_pack(custom_nodes, "MyPack")
assert pack_fs.disable_pack_native("MyPack")[0]
assert "MyPack" in pack_fs.list_disabled_packs()